+190
-12
@@ -1,14 +1,21 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { parseCidr } from "@add-ideas/toolbox-helpers";
|
||||
import { buildDnsRecord, type DnsRecordType } from "../network/dns";
|
||||
import {
|
||||
buildDnsRecord,
|
||||
inspectDnsZone,
|
||||
type DnsRecordType,
|
||||
} from "../network/dns";
|
||||
import { buildCsp, inspectHeaders } from "../network/http";
|
||||
import { lookupMime } from "../network/mime";
|
||||
import { inspectUrl } from "../network/url";
|
||||
import { parseVlsmRequirements, planVlsm } from "../network/vlsm";
|
||||
|
||||
const tabs = [
|
||||
["cidr", "IP & CIDR"],
|
||||
["vlsm", "VLSM planner"],
|
||||
["url", "URL"],
|
||||
["dns", "DNS"],
|
||||
["zone", "Zone file"],
|
||||
["http", "HTTP & CSP"],
|
||||
["mime", "MIME types"],
|
||||
] as const;
|
||||
@@ -191,6 +198,101 @@ function UrlWorkspace() {
|
||||
);
|
||||
}
|
||||
|
||||
function VlsmWorkspace() {
|
||||
const [base, setBase] = useState("10.20.0.0/22");
|
||||
const [requirements, setRequirements] = useState(
|
||||
"Office,300\nLab,120\nGuest Wi-Fi,50\nInfrastructure,20",
|
||||
);
|
||||
const plan = useMemo(
|
||||
() => attempt(() => planVlsm(base, parseVlsmRequirements(requirements))),
|
||||
[base, requirements],
|
||||
);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="vlsm-heading">
|
||||
<div>
|
||||
<h2 id="vlsm-heading">IPv4 VLSM planner</h2>
|
||||
<p className="muted">
|
||||
Allocate largest requirements first inside one base network. No router
|
||||
or IP address is contacted.
|
||||
</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Base IPv4 network</span>
|
||||
<input
|
||||
value={base}
|
||||
onChange={(event) => setBase(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Requirements — one name,hosts pair per line</span>
|
||||
<textarea
|
||||
value={requirements}
|
||||
onChange={(event) => setRequirements(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{plan.error ? (
|
||||
<p className="error" role="alert">
|
||||
{plan.error}
|
||||
</p>
|
||||
) : (
|
||||
plan.value && (
|
||||
<>
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>Base</dt>
|
||||
<dd>{plan.value.base}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Allocated addresses</dt>
|
||||
<dd>{plan.value.usedAddresses.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unallocated addresses</dt>
|
||||
<dd>{plan.value.freeAddresses.toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="table-scroll" tabIndex={0}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Requirement</th>
|
||||
<th>Network</th>
|
||||
<th>Usable range</th>
|
||||
<th>Broadcast</th>
|
||||
<th>Capacity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.value.allocations.map((allocation) => (
|
||||
<tr key={`${allocation.requestedIndex}-${allocation.name}`}>
|
||||
<td>
|
||||
{allocation.name} ({allocation.hosts})
|
||||
</td>
|
||||
<td>{allocation.network}</td>
|
||||
<td>
|
||||
{allocation.firstUsable} – {allocation.lastUsable}
|
||||
</td>
|
||||
<td>{allocation.broadcast}</td>
|
||||
<td>{allocation.usableHosts}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="compact-list muted">
|
||||
{plan.value.warnings.map((warning) => (
|
||||
<li key={warning}>{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DnsWorkspace() {
|
||||
const [owner, setOwner] = useState("www.example.com.");
|
||||
const [ttl, setTtl] = useState(3600);
|
||||
@@ -348,10 +450,15 @@ function HttpWorkspace() {
|
||||
</p>
|
||||
) : (
|
||||
parsed.value && (
|
||||
<ul className="compact-list">
|
||||
{parsed.value.findings.length ? (
|
||||
parsed.value.findings.map((finding) => (
|
||||
<li key={finding}>{finding}</li>
|
||||
<ul className="compact-list findings-list">
|
||||
{parsed.value.securityFindings.length ? (
|
||||
parsed.value.securityFindings.map((finding, index) => (
|
||||
<li
|
||||
className={`finding-${finding.severity}`}
|
||||
key={`${finding.code}-${index}`}
|
||||
>
|
||||
<strong>{finding.severity}</strong> — {finding.message}
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>No baseline header gaps detected.</li>
|
||||
@@ -399,6 +506,80 @@ function HttpWorkspace() {
|
||||
);
|
||||
}
|
||||
|
||||
function ZoneWorkspace() {
|
||||
const [source, setSource] = useState(
|
||||
"$ORIGIN example.test.\n$TTL 1h\n@ IN SOA ns1 hostmaster 2026090101 1h 15m 1w 5m\n@ IN NS ns1\nns1 IN A 192.0.2.53\nwww 300 IN A 192.0.2.80\n IN AAAA 2001:db8::80\n@ IN MX 10 mail",
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => attempt(() => inspectDnsZone(source)),
|
||||
[source],
|
||||
);
|
||||
return (
|
||||
<section className="workspace" aria-labelledby="zone-heading">
|
||||
<div>
|
||||
<h2 id="zone-heading">Inert DNS zone inspector</h2>
|
||||
<p className="muted">
|
||||
Parse bounded zone text, directives and common record data without
|
||||
resolving names, following $INCLUDE, or changing DNS.
|
||||
</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Zone text</span>
|
||||
<textarea
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</label>
|
||||
{inspection.error ? (
|
||||
<p className="error" role="alert">
|
||||
{inspection.error}
|
||||
</p>
|
||||
) : (
|
||||
inspection.value && (
|
||||
<>
|
||||
<dl className="facts">
|
||||
<div>
|
||||
<dt>Origin</dt>
|
||||
<dd>{inspection.value.origin ?? "Not declared"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Default TTL</dt>
|
||||
<dd>{inspection.value.defaultTtl ?? "Not declared"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Validated records</dt>
|
||||
<dd>{inspection.value.coverage.validatedRecords}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Syntax-only records</dt>
|
||||
<dd>{inspection.value.coverage.syntaxOnlyRecords}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{inspection.value.diagnostics.length > 0 && (
|
||||
<ul className="compact-list findings-list">
|
||||
{inspection.value.diagnostics.map((diagnostic, index) => (
|
||||
<li
|
||||
className={`finding-${diagnostic.severity}`}
|
||||
key={`${diagnostic.line}-${index}`}
|
||||
>
|
||||
Line {diagnostic.line}: {diagnostic.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<pre>
|
||||
{inspection.value.records
|
||||
.map((record) => record.normalized)
|
||||
.join("\n") || "No valid records."}
|
||||
</pre>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MimeWorkspace() {
|
||||
const [query, setQuery] = useState("");
|
||||
const matches = useMemo(() => lookupMime(query), [query]);
|
||||
@@ -460,17 +641,12 @@ export function Workbench() {
|
||||
</div>
|
||||
<span className="privacy-pill">No queries sent</span>
|
||||
</header>
|
||||
<nav
|
||||
className="workspace-tabs panel"
|
||||
aria-label="Network workspaces"
|
||||
role="tablist"
|
||||
>
|
||||
<nav className="workspace-tabs panel" aria-label="Network workspaces">
|
||||
{tabs.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === key}
|
||||
aria-pressed={tab === key}
|
||||
onClick={() => choose(key)}
|
||||
>
|
||||
{label}
|
||||
@@ -478,8 +654,10 @@ export function Workbench() {
|
||||
))}
|
||||
</nav>
|
||||
{tab === "cidr" && <CidrWorkspace />}
|
||||
{tab === "vlsm" && <VlsmWorkspace />}
|
||||
{tab === "url" && <UrlWorkspace />}
|
||||
{tab === "dns" && <DnsWorkspace />}
|
||||
{tab === "zone" && <ZoneWorkspace />}
|
||||
{tab === "http" && <HttpWorkspace />}
|
||||
{tab === "mime" && <MimeWorkspace />}
|
||||
</main>
|
||||
|
||||
+446
-4
@@ -27,10 +27,13 @@ function integer(
|
||||
return value!;
|
||||
}
|
||||
|
||||
function domain(value: string, name: string): string {
|
||||
function domain(value: string, name: string, allowWildcard = false): string {
|
||||
const candidate = value.trim();
|
||||
if (candidate === "@") return candidate;
|
||||
if (!candidate || candidate.length > 253 || !LABEL.test(candidate))
|
||||
if (candidate === "@" || candidate === ".") return candidate;
|
||||
const wildcard = allowWildcard && candidate.startsWith("*.");
|
||||
const checked = wildcard ? candidate.slice(2) : candidate;
|
||||
const maximumLength = candidate.endsWith(".") ? 254 : 253;
|
||||
if (!checked || candidate.length > maximumLength || !LABEL.test(checked))
|
||||
throw new Error(`${name} must be a valid ASCII DNS name.`);
|
||||
return candidate;
|
||||
}
|
||||
@@ -43,7 +46,7 @@ function quoteTxt(value: string): string {
|
||||
}
|
||||
|
||||
export function buildDnsRecord(input: DnsRecordInput): string {
|
||||
const owner = domain(input.owner, "Owner");
|
||||
const owner = domain(input.owner, "Owner", true);
|
||||
const ttl = integer(input.ttl, "TTL", 2_147_483_647);
|
||||
const value = input.value.trim();
|
||||
if (!value) throw new Error("Record value is required.");
|
||||
@@ -84,3 +87,442 @@ export function buildDnsRecord(input: DnsRecordInput): string {
|
||||
}
|
||||
return `${owner} ${ttl} IN ${input.type} ${data}`;
|
||||
}
|
||||
|
||||
const MAX_ZONE_CHARS = 2 * 1024 * 1024;
|
||||
const MAX_ZONE_LINES = 50_000;
|
||||
const MAX_ZONE_RECORDS = 20_000;
|
||||
const MAX_LOGICAL_LINE = 64 * 1024;
|
||||
const KNOWN_ZONE_TYPES = new Set([
|
||||
"A",
|
||||
"AAAA",
|
||||
"CAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"NS",
|
||||
"PTR",
|
||||
"SOA",
|
||||
"SRV",
|
||||
"TXT",
|
||||
]);
|
||||
|
||||
export interface ZoneDiagnostic {
|
||||
line: number;
|
||||
severity: "warning" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ZoneRecord {
|
||||
line: number;
|
||||
owner: string;
|
||||
ttl?: number;
|
||||
dnsClass: string;
|
||||
type: string;
|
||||
data: string[];
|
||||
normalized: string;
|
||||
validation: "validated" | "syntax-only";
|
||||
}
|
||||
|
||||
export interface ZoneInspection {
|
||||
origin?: string;
|
||||
defaultTtl?: number;
|
||||
records: ZoneRecord[];
|
||||
diagnostics: ZoneDiagnostic[];
|
||||
coverage: {
|
||||
logicalLines: number;
|
||||
validatedRecords: number;
|
||||
syntaxOnlyRecords: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface LogicalZoneLine {
|
||||
line: number;
|
||||
text: string;
|
||||
ownerOmitted: boolean;
|
||||
}
|
||||
|
||||
function stripZoneComment(line: string): string {
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const character = line[index]!;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') quoted = !quoted;
|
||||
if (character === ";" && !quoted) return line.slice(0, index);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
function logicalZoneLines(source: string): LogicalZoneLine[] {
|
||||
if (source.length > MAX_ZONE_CHARS)
|
||||
throw new Error("Zone text exceeds the 2 MiB limit.");
|
||||
const physical = source
|
||||
.replaceAll("\r\n", "\n")
|
||||
.replaceAll("\r", "\n")
|
||||
.split("\n");
|
||||
if (physical.length > MAX_ZONE_LINES)
|
||||
throw new Error(
|
||||
`Zone text exceeds ${MAX_ZONE_LINES.toLocaleString()} physical lines.`,
|
||||
);
|
||||
const result: LogicalZoneLine[] = [];
|
||||
let buffer = "";
|
||||
let start = 1;
|
||||
let ownerOmitted = false;
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (const [index, raw] of physical.entries()) {
|
||||
const withoutComment = stripZoneComment(raw);
|
||||
if (!buffer) {
|
||||
start = index + 1;
|
||||
ownerOmitted = /^[ \t]/u.test(raw);
|
||||
}
|
||||
let segment = "";
|
||||
for (const character of withoutComment) {
|
||||
if (escaped) {
|
||||
segment += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
segment += character;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') quoted = !quoted;
|
||||
if (!quoted && character === "(") {
|
||||
depth += 1;
|
||||
if (depth > 1)
|
||||
throw new Error(
|
||||
`Line ${index + 1}: nested parentheses are not supported.`,
|
||||
);
|
||||
segment += " ";
|
||||
} else if (!quoted && character === ")") {
|
||||
depth -= 1;
|
||||
if (depth < 0)
|
||||
throw new Error(`Line ${index + 1}: unmatched closing parenthesis.`);
|
||||
segment += " ";
|
||||
} else segment += character;
|
||||
}
|
||||
buffer += `${buffer ? " " : ""}${segment.trim()}`;
|
||||
if (buffer.length > MAX_LOGICAL_LINE)
|
||||
throw new Error(`Line ${start}: logical record exceeds 64 KiB.`);
|
||||
if (depth === 0 && !quoted) {
|
||||
if (buffer.trim())
|
||||
result.push({ line: start, text: buffer.trim(), ownerOmitted });
|
||||
buffer = "";
|
||||
escaped = false;
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error(`Line ${start}: unterminated quoted string.`);
|
||||
if (depth !== 0)
|
||||
throw new Error(`Line ${start}: unterminated parenthesized record.`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function zoneTokens(text: string, line: number): string[] {
|
||||
const tokens: string[] = [];
|
||||
let token = "";
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
let active = false;
|
||||
for (const character of text) {
|
||||
if (escaped) {
|
||||
token += character;
|
||||
escaped = false;
|
||||
active = true;
|
||||
} else if (character === "\\") {
|
||||
escaped = true;
|
||||
active = true;
|
||||
} else if (character === '"') {
|
||||
quoted = !quoted;
|
||||
active = true;
|
||||
} else if (/\s/u.test(character) && !quoted) {
|
||||
if (active) tokens.push(token);
|
||||
token = "";
|
||||
active = false;
|
||||
} else {
|
||||
token += character;
|
||||
active = true;
|
||||
}
|
||||
}
|
||||
if (escaped || quoted)
|
||||
throw new Error(`Line ${line}: malformed quoted or escaped value.`);
|
||||
if (active) tokens.push(token);
|
||||
if (tokens.length > 512) throw new Error(`Line ${line}: too many fields.`);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function ttlValue(text: string, label: string): number {
|
||||
if (!/^(?:\d+[WDHMSwdhms]?)+$/u.test(text))
|
||||
throw new Error(`${label} must be seconds or a sequence such as 1h30m.`);
|
||||
let total = 0;
|
||||
for (const match of text.matchAll(/(\d+)([WDHMSwdhms]?)/gu)) {
|
||||
const multiplier =
|
||||
match[2]?.toLowerCase() === "w"
|
||||
? 604_800
|
||||
: match[2]?.toLowerCase() === "d"
|
||||
? 86_400
|
||||
: match[2]?.toLowerCase() === "h"
|
||||
? 3_600
|
||||
: match[2]?.toLowerCase() === "m"
|
||||
? 60
|
||||
: 1;
|
||||
total += Number(match[1]) * multiplier;
|
||||
}
|
||||
if (!Number.isSafeInteger(total) || total > 2_147_483_647)
|
||||
throw new Error(`${label} exceeds 2,147,483,647 seconds.`);
|
||||
return total;
|
||||
}
|
||||
|
||||
function absoluteZoneName(value: string, origin?: string): string {
|
||||
if (value === "@") {
|
||||
if (!origin) throw new Error("@ requires a preceding $ORIGIN directive.");
|
||||
return origin;
|
||||
}
|
||||
domain(value, "DNS name");
|
||||
if (value.endsWith(".") || !origin) return value;
|
||||
const absolute = origin === "." ? `${value}.` : `${value}.${origin}`;
|
||||
domain(absolute, "Absolute DNS name");
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function absoluteZoneOwner(value: string, origin?: string): string {
|
||||
if (value === "@") return absoluteZoneName(value, origin);
|
||||
domain(value, "DNS owner", true);
|
||||
if (value.endsWith(".") || !origin) return value;
|
||||
const absolute = origin === "." ? `${value}.` : `${value}.${origin}`;
|
||||
domain(absolute, "Absolute DNS owner", true);
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function normalizedSyntaxToken(value: string): string {
|
||||
return /[\s;()"\\]/u.test(value)
|
||||
? `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||
: value;
|
||||
}
|
||||
|
||||
function zoneUnsigned(
|
||||
value: string,
|
||||
name: string,
|
||||
maximum = 4_294_967_295,
|
||||
): number {
|
||||
if (!/^\d+$/u.test(value))
|
||||
throw new Error(`${name} must be an unsigned integer.`);
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed > maximum)
|
||||
throw new Error(`${name} must be no greater than ${maximum}.`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateZoneData(
|
||||
type: string,
|
||||
values: string[],
|
||||
origin?: string,
|
||||
): string[] {
|
||||
switch (type) {
|
||||
case "A":
|
||||
if (values.length !== 1)
|
||||
throw new Error("A requires exactly one address.");
|
||||
return [parseIpv4(values[0]!).canonical];
|
||||
case "AAAA":
|
||||
if (values.length !== 1)
|
||||
throw new Error("AAAA requires exactly one address.");
|
||||
return [parseIpv6(values[0]!).canonical];
|
||||
case "CNAME":
|
||||
case "NS":
|
||||
case "PTR":
|
||||
if (values.length !== 1)
|
||||
throw new Error(`${type} requires exactly one target.`);
|
||||
return [absoluteZoneName(values[0]!, origin)];
|
||||
case "MX":
|
||||
if (values.length !== 2)
|
||||
throw new Error("MX requires preference and target.");
|
||||
return [
|
||||
String(zoneUnsigned(values[0]!, "MX preference", 65_535)),
|
||||
absoluteZoneName(values[1]!, origin),
|
||||
];
|
||||
case "SRV":
|
||||
if (values.length !== 4)
|
||||
throw new Error("SRV requires priority, weight, port and target.");
|
||||
return [
|
||||
String(zoneUnsigned(values[0]!, "SRV priority", 65_535)),
|
||||
String(zoneUnsigned(values[1]!, "SRV weight", 65_535)),
|
||||
String(zoneUnsigned(values[2]!, "SRV port", 65_535)),
|
||||
values[3] === "." ? "." : absoluteZoneName(values[3]!, origin),
|
||||
];
|
||||
case "CAA":
|
||||
if (values.length !== 3)
|
||||
throw new Error("CAA requires flags, tag and value.");
|
||||
if (!["issue", "issuewild", "iodef"].includes(values[1]!))
|
||||
throw new Error("CAA tag must be issue, issuewild or iodef.");
|
||||
return [
|
||||
String(zoneUnsigned(values[0]!, "CAA flags", 255)),
|
||||
values[1]!,
|
||||
quoteTxt(values[2]!),
|
||||
];
|
||||
case "TXT":
|
||||
if (values.length < 1)
|
||||
throw new Error("TXT requires at least one character-string.");
|
||||
return values.map(quoteTxt);
|
||||
case "SOA":
|
||||
if (values.length !== 7)
|
||||
throw new Error(
|
||||
"SOA requires MNAME, RNAME, serial, refresh, retry, expire and minimum.",
|
||||
);
|
||||
return [
|
||||
absoluteZoneName(values[0]!, origin),
|
||||
absoluteZoneName(values[1]!, origin),
|
||||
String(zoneUnsigned(values[2]!, "SOA serial")),
|
||||
...values
|
||||
.slice(3)
|
||||
.map((value, index) =>
|
||||
String(
|
||||
ttlValue(
|
||||
value,
|
||||
["SOA refresh", "SOA retry", "SOA expire", "SOA minimum"][
|
||||
index
|
||||
]!,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectDnsZone(source: string): ZoneInspection {
|
||||
const lines = logicalZoneLines(source);
|
||||
const records: ZoneRecord[] = [];
|
||||
const diagnostics: ZoneDiagnostic[] = [];
|
||||
let origin: string | undefined;
|
||||
let defaultTtl: number | undefined;
|
||||
let previousOwner: string | undefined;
|
||||
for (const logical of lines) {
|
||||
try {
|
||||
const tokens = zoneTokens(logical.text, logical.line);
|
||||
const directive = tokens[0]?.toUpperCase();
|
||||
if (directive?.startsWith("$")) {
|
||||
if (directive === "$ORIGIN") {
|
||||
if (tokens.length !== 2)
|
||||
throw new Error("$ORIGIN requires one DNS name.");
|
||||
const next = domain(tokens[1]!, "Origin");
|
||||
origin = next.endsWith(".") ? next : `${next}.`;
|
||||
} else if (directive === "$TTL") {
|
||||
if (tokens.length !== 2)
|
||||
throw new Error("$TTL requires one duration.");
|
||||
defaultTtl = ttlValue(tokens[1]!, "$TTL");
|
||||
} else {
|
||||
throw new Error(
|
||||
`${directive} is intentionally unsupported; external includes and generated records are never followed.`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (records.length >= MAX_ZONE_RECORDS)
|
||||
throw new Error(
|
||||
`Zone exceeds ${MAX_ZONE_RECORDS.toLocaleString()} records.`,
|
||||
);
|
||||
|
||||
let cursor = 0;
|
||||
let owner: string;
|
||||
if (logical.ownerOmitted) {
|
||||
if (!previousOwner)
|
||||
throw new Error(
|
||||
"An omitted owner has no previous record to inherit.",
|
||||
);
|
||||
owner = previousOwner;
|
||||
} else {
|
||||
owner = absoluteZoneOwner(tokens[cursor++] ?? "", origin);
|
||||
}
|
||||
let ttl: number | undefined;
|
||||
let dnsClass = "IN";
|
||||
let type = "";
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor]!;
|
||||
const upper = token.toUpperCase();
|
||||
if (/^(?:\d+[WDHMSwdhms]?)+$/u.test(token) && ttl === undefined)
|
||||
ttl = ttlValue(token, "TTL");
|
||||
else if (["IN", "CH", "HS"].includes(upper)) dnsClass = upper;
|
||||
else {
|
||||
type = upper;
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
if (!type || !/^(?:[A-Z][A-Z0-9-]{0,31}|TYPE\d{1,5})$/u.test(type))
|
||||
throw new Error("Record type is missing or invalid.");
|
||||
if (type.startsWith("TYPE") && Number(type.slice(4)) > 65_535)
|
||||
throw new Error("Generic TYPE code must be from 0 to 65535.");
|
||||
const rawData = tokens.slice(cursor);
|
||||
if (rawData.length === 0)
|
||||
throw new Error(`${type} record data is missing.`);
|
||||
const known = KNOWN_ZONE_TYPES.has(type);
|
||||
if (!known)
|
||||
diagnostics.push({
|
||||
line: logical.line,
|
||||
severity: "warning",
|
||||
message: `${type} was retained as syntax-only data; its RDATA was not semantically validated.`,
|
||||
});
|
||||
if (dnsClass !== "IN")
|
||||
diagnostics.push({
|
||||
line: logical.line,
|
||||
severity: "warning",
|
||||
message: `${dnsClass} class was parsed but only IN record semantics are validated.`,
|
||||
});
|
||||
const semanticallyValidated = known && dnsClass === "IN";
|
||||
const data = semanticallyValidated
|
||||
? validateZoneData(type, rawData, origin)
|
||||
: rawData;
|
||||
const normalizedData = semanticallyValidated
|
||||
? data
|
||||
: data.map((value) => normalizedSyntaxToken(value));
|
||||
const effectiveTtl = ttl ?? defaultTtl;
|
||||
records.push({
|
||||
line: logical.line,
|
||||
owner,
|
||||
...(effectiveTtl === undefined ? {} : { ttl: effectiveTtl }),
|
||||
dnsClass,
|
||||
type,
|
||||
data,
|
||||
normalized: [owner, effectiveTtl, dnsClass, type, ...normalizedData]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(" "),
|
||||
validation: semanticallyValidated ? "validated" : "syntax-only",
|
||||
});
|
||||
previousOwner = owner;
|
||||
} catch (error) {
|
||||
diagnostics.push({
|
||||
line: logical.line,
|
||||
severity: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Record could not be parsed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...(origin ? { origin } : {}),
|
||||
...(defaultTtl === undefined ? {} : { defaultTtl }),
|
||||
records,
|
||||
diagnostics,
|
||||
coverage: {
|
||||
logicalLines: lines.length,
|
||||
validatedRecords: records.filter(
|
||||
(record) => record.validation === "validated",
|
||||
).length,
|
||||
syntaxOnlyRecords: records.filter(
|
||||
(record) => record.validation === "syntax-only",
|
||||
).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+275
-10
@@ -7,6 +7,25 @@ export interface HeaderInspection {
|
||||
headers: HeaderEntry[];
|
||||
duplicates: string[];
|
||||
findings: string[];
|
||||
securityFindings: SecurityHeaderFinding[];
|
||||
csp?: CspInspection;
|
||||
cspFields: CspInspection[];
|
||||
}
|
||||
|
||||
export interface SecurityHeaderFinding {
|
||||
severity: "info" | "warning" | "error";
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CspDirective {
|
||||
name: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
export interface CspInspection {
|
||||
directives: CspDirective[];
|
||||
findings: SecurityHeaderFinding[];
|
||||
}
|
||||
|
||||
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
|
||||
@@ -42,7 +61,11 @@ export function inspectHeaders(source: string): HeaderInspection {
|
||||
throw new Error(`Line ${index + 1}: invalid header name.`);
|
||||
if (hasInvalidHeaderControl(value))
|
||||
throw new Error(`Line ${index + 1}: invalid control character.`);
|
||||
if (value.length > 64 * 1024)
|
||||
throw new Error(`Line ${index + 1}: header value exceeds 64 KiB.`);
|
||||
headers.push({ name, value });
|
||||
if (headers.length > 2_048)
|
||||
throw new Error("Header block contains more than 2,048 fields.");
|
||||
}
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
@@ -54,18 +77,260 @@ export function inspectHeaders(source: string): HeaderInspection {
|
||||
.filter(([, count]) => count > 1)
|
||||
.map(([name]) => name);
|
||||
const names = new Set(counts.keys());
|
||||
const findings: string[] = [];
|
||||
if (!names.has("content-security-policy"))
|
||||
findings.push("No Content-Security-Policy header is present.");
|
||||
if (!names.has("x-content-type-options"))
|
||||
findings.push("Add X-Content-Type-Options: nosniff.");
|
||||
if (!names.has("referrer-policy"))
|
||||
findings.push("No Referrer-Policy header is present.");
|
||||
const securityFindings: SecurityHeaderFinding[] = [];
|
||||
const add = (
|
||||
severity: SecurityHeaderFinding["severity"],
|
||||
code: string,
|
||||
message: string,
|
||||
) => securityFindings.push({ severity, code, message });
|
||||
const values = (name: string) =>
|
||||
headers
|
||||
.filter((header) => header.name.toLowerCase() === name)
|
||||
.map((header) => header.value);
|
||||
const first = (name: string) => values(name)[0];
|
||||
|
||||
const cspValues = values("content-security-policy");
|
||||
if (cspValues.length === 0)
|
||||
add(
|
||||
"error",
|
||||
"csp-missing",
|
||||
"No Content-Security-Policy header is present.",
|
||||
);
|
||||
else if (cspValues.length > 1)
|
||||
add(
|
||||
"warning",
|
||||
"csp-multiple",
|
||||
"Multiple enforcing Content-Security-Policy fields intersect; inspect each deployed field explicitly.",
|
||||
);
|
||||
const cspFields = cspValues.map((policy, fieldIndex) => {
|
||||
const inspected = inspectCsp(policy);
|
||||
securityFindings.push(
|
||||
...inspected.findings.map((finding) => ({
|
||||
...finding,
|
||||
message:
|
||||
cspValues.length > 1
|
||||
? `CSP field ${fieldIndex + 1}: ${finding.message}`
|
||||
: finding.message,
|
||||
})),
|
||||
);
|
||||
return inspected;
|
||||
});
|
||||
const csp = cspFields[0];
|
||||
|
||||
if (first("x-content-type-options")?.toLowerCase() !== "nosniff")
|
||||
add("warning", "nosniff", "Add X-Content-Type-Options: nosniff.");
|
||||
const referrer = first("referrer-policy")?.toLowerCase();
|
||||
if (!referrer)
|
||||
add("warning", "referrer-policy", "No Referrer-Policy header is present.");
|
||||
else if (/unsafe-url|no-referrer-when-downgrade/u.test(referrer))
|
||||
add(
|
||||
"warning",
|
||||
"referrer-policy-weak",
|
||||
`Referrer-Policy “${referrer}” can disclose full paths cross-origin.`,
|
||||
);
|
||||
if (!names.has("permissions-policy"))
|
||||
findings.push("No Permissions-Policy header is present.");
|
||||
add(
|
||||
"info",
|
||||
"permissions-policy",
|
||||
"No Permissions-Policy header is present.",
|
||||
);
|
||||
if (!names.has("strict-transport-security"))
|
||||
add(
|
||||
"warning",
|
||||
"hsts-missing",
|
||||
"No Strict-Transport-Security header is present (relevant on HTTPS responses).",
|
||||
);
|
||||
else {
|
||||
const hsts = first("strict-transport-security")!;
|
||||
const age = hsts.match(/(?:^|;)\s*max-age\s*=\s*(\d+)/iu);
|
||||
if (!age)
|
||||
add(
|
||||
"error",
|
||||
"hsts-invalid",
|
||||
"Strict-Transport-Security has no valid max-age directive.",
|
||||
);
|
||||
else if (Number(age[1]) < 15_552_000)
|
||||
add(
|
||||
"warning",
|
||||
"hsts-short",
|
||||
"Strict-Transport-Security max-age is shorter than 180 days.",
|
||||
);
|
||||
}
|
||||
if (headers.some((header) => header.name.toLowerCase() === "server"))
|
||||
findings.push("The Server header may disclose implementation details.");
|
||||
return { headers, duplicates, findings };
|
||||
add(
|
||||
"info",
|
||||
"server-disclosure",
|
||||
"The Server header may disclose implementation details.",
|
||||
);
|
||||
|
||||
for (const cookie of values("set-cookie")) {
|
||||
const cookieName = cookie.split("=", 1)[0]?.trim() || "(unnamed)";
|
||||
if (!/;\s*Secure(?:;|$)/iu.test(cookie))
|
||||
add(
|
||||
"warning",
|
||||
"cookie-secure",
|
||||
`Cookie ${cookieName} does not declare Secure.`,
|
||||
);
|
||||
if (!/;\s*HttpOnly(?:;|$)/iu.test(cookie))
|
||||
add(
|
||||
"warning",
|
||||
"cookie-http-only",
|
||||
`Cookie ${cookieName} does not declare HttpOnly.`,
|
||||
);
|
||||
if (!/;\s*SameSite=(?:Strict|Lax|None)(?:;|$)/iu.test(cookie))
|
||||
add(
|
||||
"warning",
|
||||
"cookie-samesite",
|
||||
`Cookie ${cookieName} has no explicit valid SameSite attribute.`,
|
||||
);
|
||||
if (
|
||||
/;\s*SameSite=None(?:;|$)/iu.test(cookie) &&
|
||||
!/;\s*Secure(?:;|$)/iu.test(cookie)
|
||||
)
|
||||
add(
|
||||
"error",
|
||||
"cookie-none-insecure",
|
||||
`Cookie ${cookieName} uses SameSite=None without Secure.`,
|
||||
);
|
||||
}
|
||||
|
||||
const allowOrigin = first("access-control-allow-origin");
|
||||
const allowCredentials = first("access-control-allow-credentials");
|
||||
if (allowOrigin === "*" && allowCredentials?.toLowerCase() === "true")
|
||||
add(
|
||||
"error",
|
||||
"cors-wildcard-credentials",
|
||||
"Wildcard Access-Control-Allow-Origin cannot be combined safely with credentials.",
|
||||
);
|
||||
else if (allowOrigin === "*")
|
||||
add(
|
||||
"info",
|
||||
"cors-wildcard",
|
||||
"Access-Control-Allow-Origin permits every origin; verify that the response is intentionally public.",
|
||||
);
|
||||
|
||||
const frameAncestors = cspFields.some((field) =>
|
||||
field.directives.some((directive) => directive.name === "frame-ancestors"),
|
||||
);
|
||||
if (!frameAncestors) {
|
||||
const xFrameOptions = first("x-frame-options")?.trim().toUpperCase();
|
||||
if (!xFrameOptions)
|
||||
add(
|
||||
"warning",
|
||||
"framing",
|
||||
"No frame-ancestors directive or X-Frame-Options fallback was found.",
|
||||
);
|
||||
else if (!["DENY", "SAMEORIGIN"].includes(xFrameOptions))
|
||||
add(
|
||||
"warning",
|
||||
"x-frame-options-invalid",
|
||||
"X-Frame-Options is present but is not the supported DENY or SAMEORIGIN value.",
|
||||
);
|
||||
}
|
||||
if (!names.has("cross-origin-opener-policy"))
|
||||
add(
|
||||
"info",
|
||||
"coop",
|
||||
"Cross-Origin-Opener-Policy is not set; isolation-sensitive applications should review it.",
|
||||
);
|
||||
if (!names.has("cross-origin-resource-policy"))
|
||||
add(
|
||||
"info",
|
||||
"corp",
|
||||
"Cross-Origin-Resource-Policy is not set; review cross-origin embedding needs.",
|
||||
);
|
||||
|
||||
return {
|
||||
headers,
|
||||
duplicates,
|
||||
findings: securityFindings.map((finding) => finding.message),
|
||||
securityFindings,
|
||||
cspFields,
|
||||
...(csp ? { csp } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function inspectCsp(policy: string): CspInspection {
|
||||
if (policy.length > 128 * 1024)
|
||||
throw new Error("CSP exceeds the 128 KiB inspection limit.");
|
||||
const directives: CspDirective[] = [];
|
||||
const findings: SecurityHeaderFinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (
|
||||
severity: SecurityHeaderFinding["severity"],
|
||||
code: string,
|
||||
message: string,
|
||||
) => findings.push({ severity, code, message });
|
||||
for (const raw of policy.split(";")) {
|
||||
const tokens = raw.trim().split(/\s+/u).filter(Boolean);
|
||||
if (tokens.length === 0) continue;
|
||||
const name = tokens[0]!.toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name)) {
|
||||
add(
|
||||
"error",
|
||||
"csp-directive-name",
|
||||
`Invalid CSP directive name “${name}”.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const duplicate = seen.has(name);
|
||||
if (duplicate)
|
||||
add(
|
||||
"warning",
|
||||
"csp-duplicate-directive",
|
||||
`Duplicate ${name} directive is ignored by browsers after its first occurrence.`,
|
||||
);
|
||||
seen.add(name);
|
||||
const values = tokens.slice(1);
|
||||
const normalizedValues = values.map((value) => value.toLowerCase());
|
||||
directives.push({ name, values });
|
||||
// CSP processors ignore duplicate directives after the first occurrence.
|
||||
// Keep them visible above, but do not attribute their ignored sources to
|
||||
// the effective policy.
|
||||
if (duplicate) continue;
|
||||
if (normalizedValues.includes("*"))
|
||||
add("warning", "csp-wildcard", `${name} contains a wildcard source.`);
|
||||
if (normalizedValues.includes("'unsafe-eval'"))
|
||||
add("error", "csp-unsafe-eval", `${name} permits 'unsafe-eval'.`);
|
||||
if (normalizedValues.includes("'unsafe-inline'"))
|
||||
add(
|
||||
"warning",
|
||||
"csp-unsafe-inline",
|
||||
`${name} permits 'unsafe-inline'; hashes/nonces are usually safer.`,
|
||||
);
|
||||
if (normalizedValues.includes("http:"))
|
||||
add("warning", "csp-http", `${name} permits the insecure http: scheme.`);
|
||||
if (
|
||||
(name === "script-src" || name === "script-src-elem") &&
|
||||
normalizedValues.includes("data:")
|
||||
)
|
||||
add("error", "csp-script-data", `${name} permits data: script sources.`);
|
||||
}
|
||||
if (!seen.has("default-src"))
|
||||
add("warning", "csp-default-src", "CSP has no default-src fallback.");
|
||||
const objectSource = directives.find(
|
||||
(directive) => directive.name === "object-src",
|
||||
);
|
||||
if (!objectSource)
|
||||
add("warning", "csp-object-src", "CSP has no explicit object-src 'none'.");
|
||||
else if (!(
|
||||
objectSource.values.length === 1 &&
|
||||
objectSource.values[0]?.toLowerCase() === "'none'"
|
||||
))
|
||||
add(
|
||||
"warning",
|
||||
"csp-object-src-open",
|
||||
"object-src is not restricted to 'none'.",
|
||||
);
|
||||
if (!seen.has("base-uri"))
|
||||
add("warning", "csp-base-uri", "CSP has no base-uri restriction.");
|
||||
if (!seen.has("frame-ancestors"))
|
||||
add(
|
||||
"warning",
|
||||
"csp-frame-ancestors",
|
||||
"CSP has no frame-ancestors restriction.",
|
||||
);
|
||||
return { directives, findings };
|
||||
}
|
||||
|
||||
export interface CspInput {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { formatIpv4, parseCidr } from "@add-ideas/toolbox-helpers";
|
||||
|
||||
const MAX_REQUIREMENTS = 1_024;
|
||||
const MAX_NAME_LENGTH = 120;
|
||||
|
||||
export interface VlsmRequirement {
|
||||
name: string;
|
||||
hosts: number;
|
||||
}
|
||||
|
||||
export interface VlsmAllocation extends VlsmRequirement {
|
||||
requestedIndex: number;
|
||||
prefixLength: number;
|
||||
network: string;
|
||||
firstUsable: string;
|
||||
lastUsable: string;
|
||||
broadcast: string;
|
||||
usableHosts: number;
|
||||
addresses: number;
|
||||
}
|
||||
|
||||
export interface VlsmPlan {
|
||||
base: string;
|
||||
allocations: VlsmAllocation[];
|
||||
usedAddresses: number;
|
||||
freeAddresses: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function prefixForHosts(hosts: number): number {
|
||||
if (!Number.isSafeInteger(hosts) || hosts < 1 || hosts > 4_294_967_294)
|
||||
throw new Error(
|
||||
"Host requirements must be integers from 1 to 4,294,967,294.",
|
||||
);
|
||||
const addressBits = Math.ceil(Math.log2(hosts + 2));
|
||||
return 32 - Math.max(2, addressBits);
|
||||
}
|
||||
|
||||
function align(value: bigint, block: bigint): bigint {
|
||||
const remainder = value % block;
|
||||
return remainder === 0n ? value : value + block - remainder;
|
||||
}
|
||||
|
||||
export function parseVlsmRequirements(source: string): VlsmRequirement[] {
|
||||
if (source.length > 256 * 1024)
|
||||
throw new Error("Requirements exceed the 256 KiB limit.");
|
||||
const requirements: VlsmRequirement[] = [];
|
||||
for (const [index, raw] of source
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split("\n")
|
||||
.entries()) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (requirements.length >= MAX_REQUIREMENTS)
|
||||
throw new Error(
|
||||
`More than ${MAX_REQUIREMENTS} subnet requirements were supplied.`,
|
||||
);
|
||||
const separator = line.lastIndexOf(",");
|
||||
if (separator < 1)
|
||||
throw new Error(`Line ${index + 1}: expected name,hosts.`);
|
||||
const name = line.slice(0, separator).trim();
|
||||
const hostsText = line.slice(separator + 1).trim();
|
||||
if (!name || name.length > MAX_NAME_LENGTH)
|
||||
throw new Error(
|
||||
`Line ${index + 1}: name must contain 1–${MAX_NAME_LENGTH} characters.`,
|
||||
);
|
||||
if (!/^\d+$/u.test(hostsText))
|
||||
throw new Error(`Line ${index + 1}: hosts must be a positive integer.`);
|
||||
const hosts = Number(hostsText);
|
||||
prefixForHosts(hosts);
|
||||
requirements.push({ name, hosts });
|
||||
}
|
||||
if (requirements.length === 0)
|
||||
throw new Error("Enter at least one name,hosts requirement.");
|
||||
return requirements;
|
||||
}
|
||||
|
||||
export function planVlsm(
|
||||
baseCidr: string,
|
||||
requirements: readonly VlsmRequirement[],
|
||||
): VlsmPlan {
|
||||
if (requirements.length === 0 || requirements.length > MAX_REQUIREMENTS)
|
||||
throw new Error(
|
||||
`Supply between 1 and ${MAX_REQUIREMENTS} subnet requirements.`,
|
||||
);
|
||||
const base = parseCidr(baseCidr);
|
||||
if (base.version !== 4)
|
||||
throw new Error(
|
||||
"The VLSM planner currently supports IPv4 base networks only.",
|
||||
);
|
||||
|
||||
const sorted = requirements
|
||||
.map((requirement, requestedIndex) => ({
|
||||
...requirement,
|
||||
requestedIndex,
|
||||
prefixLength: prefixForHosts(requirement.hosts),
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.prefixLength - right.prefixLength ||
|
||||
left.requestedIndex - right.requestedIndex,
|
||||
);
|
||||
|
||||
let cursor = base.network.value;
|
||||
const endExclusive = base.network.value + base.size;
|
||||
const allocations: VlsmAllocation[] = [];
|
||||
for (const requirement of sorted) {
|
||||
if (requirement.prefixLength < base.prefixLength)
|
||||
throw new Error(
|
||||
`${requirement.name} needs /${requirement.prefixLength}, which is larger than ${base.canonical}.`,
|
||||
);
|
||||
const addresses = 2 ** (32 - requirement.prefixLength);
|
||||
const block = BigInt(addresses);
|
||||
const network = align(cursor, block);
|
||||
const after = network + block;
|
||||
if (after > endExclusive)
|
||||
throw new Error(
|
||||
`${requirement.name} does not fit. Allocation stopped before producing a partial plan.`,
|
||||
);
|
||||
allocations.push({
|
||||
name: requirement.name,
|
||||
hosts: requirement.hosts,
|
||||
requestedIndex: requirement.requestedIndex,
|
||||
prefixLength: requirement.prefixLength,
|
||||
network: `${formatIpv4(network)}/${requirement.prefixLength}`,
|
||||
firstUsable: formatIpv4(network + 1n),
|
||||
lastUsable: formatIpv4(after - 2n),
|
||||
broadcast: formatIpv4(after - 1n),
|
||||
usableHosts: addresses - 2,
|
||||
addresses,
|
||||
});
|
||||
cursor = after;
|
||||
}
|
||||
|
||||
const used = allocations.reduce((total, item) => total + item.addresses, 0);
|
||||
const baseSize = Number(base.size);
|
||||
return {
|
||||
base: base.canonical,
|
||||
allocations,
|
||||
usedAddresses: used,
|
||||
freeAddresses: baseSize - used,
|
||||
warnings: [
|
||||
"Allocations are largest-first and use conventional network/broadcast reservations; point-to-point /31 semantics are not assumed.",
|
||||
"Free-address count includes alignment gaps and is not itself a contiguous remainder guarantee.",
|
||||
],
|
||||
};
|
||||
}
|
||||
+22
-1
@@ -140,7 +140,7 @@ textarea {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.8rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
.workspace-tabs button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
@@ -230,6 +230,27 @@ table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.table-scroll {
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.68rem;
|
||||
}
|
||||
.table-scroll table {
|
||||
min-width: 52rem;
|
||||
}
|
||||
.findings-list li + li {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.finding-error {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.finding-warning {
|
||||
color: #8a5a00;
|
||||
}
|
||||
.finding-info {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 0.55rem 0.65rem;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.network-tools",
|
||||
"name": "Network Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Calculate and construct network values locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,32 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".txt", ".zone"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".txt", ".zone"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": []
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user