From 37118423a53fd5d0005b428adfde4e97c5742aa3 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Mon, 5 Oct 2015 13:35:01 -0400 Subject: [PATCH 01/36] Add support for Quay's vulnerability tool --- data/database.py | 4 ++ ...c20cc_backfill_parent_ids_and_checksums.py | 21 ++++++ ...add_support_for_quay_s_security_indexer.py | 12 ++-- initdb.py | 4 ++ test/data/test.db | Bin 913408 -> 376832 bytes util/migrate/backfill_checksums.py | 67 ++++++++++++++++++ util/migrate/backfill_parent_id.py | 52 ++++++++------ workers/securityworker.py | 7 +- 8 files changed, 135 insertions(+), 32 deletions(-) create mode 100644 data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py create mode 100644 util/migrate/backfill_checksums.py diff --git a/data/database.py b/data/database.py index c87ece328..4c90d9947 100644 --- a/data/database.py +++ b/data/database.py @@ -577,6 +577,10 @@ class Image(BaseModel): security_indexed_engine = IntegerField(default=-1) parent = ForeignKeyField('self', index=True, null=True, related_name='children') + security_indexed = BooleanField(default=False) + security_indexed_engine = IntegerField(default=-1) + parent = ForeignKeyField('self', index=True, null=True, related_name='children') + class Meta: database = db read_slaves = (read_slave,) diff --git a/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py b/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py new file mode 100644 index 000000000..afd589809 --- /dev/null +++ b/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py @@ -0,0 +1,21 @@ +"""backfill parent ids and checksums +Revision ID: 2fb9492c20cc +Revises: 57dad559ff2d +Create Date: 2015-07-14 17:38:47.397963 +""" + +# revision identifiers, used by Alembic. +revision = '2fb9492c20cc' +down_revision = '57dad559ff2d' + +from alembic import op +import sqlalchemy as sa +from util.migrate.backfill_parent_id import backfill_parent_id +from util.migrate.backfill_checksums import backfill_checksums + +def upgrade(tables): + backfill_parent_id() + backfill_checksums() + +def downgrade(tables): + pass diff --git a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py index 9e4d0e6c2..7ba826b14 100644 --- a/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py +++ b/data/migrations/versions/57dad559ff2d_add_support_for_quay_s_security_indexer.py @@ -1,14 +1,12 @@ """add support for quay's security indexer - Revision ID: 57dad559ff2d Revises: 154f2befdfbe Create Date: 2015-07-13 16:51:41.669249 - """ # revision identifiers, used by Alembic. revision = '57dad559ff2d' -down_revision = '73669db7e12' +down_revision = '3ff4fbc94644' from alembic import op import sqlalchemy as sa @@ -16,19 +14,19 @@ import sqlalchemy as sa def upgrade(tables): ### commands auto generated by Alembic - please adjust! ### op.add_column('image', sa.Column('parent_id', sa.Integer(), nullable=True)) - op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False, default=False, server_default=sa.sql.expression.false())) - op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False, default=-1, server_default="-1")) + op.add_column('image', sa.Column('security_indexed', sa.Boolean(), nullable=False)) + op.add_column('image', sa.Column('security_indexed_engine', sa.Integer(), nullable=False)) op.create_index('image_parent_id', 'image', ['parent_id'], unique=False) op.create_foreign_key(op.f('fk_image_parent_id_image'), 'image', 'image', ['parent_id'], ['id']) ### end Alembic commands ### op.create_index('image_security_indexed_engine_security_indexed', 'image', ['security_indexed_engine', 'security_indexed']) def downgrade(tables): - ### commands auto generated by Alembic - please adjust! ### - op.drop_index('image_security_indexed_engine_security_indexed', 'image') + ### commands auto generated by Alembic - please adjust! ### op.drop_constraint(op.f('fk_image_parent_id_image'), 'image', type_='foreignkey') op.drop_index('image_parent_id', table_name='image') op.drop_column('image', 'security_indexed') op.drop_column('image', 'security_indexed_engine') op.drop_column('image', 'parent_id') ### end Alembic commands ### + op.drop_index('image_security_indexed', 'image') diff --git a/initdb.py b/initdb.py index 80c9fa952..8b095b002 100644 --- a/initdb.py +++ b/initdb.py @@ -95,6 +95,10 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): for path_builder in paths: path = path_builder(new_image.storage.uuid) store.put_content('local_us', path, checksum) + + new_image.security_indexed = False + new_image.security_indexed_engine = maxsize + new_image.save() new_image.security_indexed = False new_image.security_indexed_engine = maxsize diff --git a/test/data/test.db b/test/data/test.db index a301a7fa7b9c497a92d2d79db7742a6ae57927e0..6b318ccd35bcfe1ce60d788a341d160f7a773de7 100644 GIT binary patch delta 50789 zcmeFacYIVu|1W-KW_uwC5CSQX2C18DFCdUY5(4QE2vNXIvZ1DsLRG?I#fG@ZfJzY_ zdjnAeNK+pxDq`>0d#@;#`<}C#5Q_TT-}8O_{@D^xdx1U6o@D=K2iP8VKikgkU^lZH*$s@dYuJ_SQg#7b&K9#aHlH=J zb6G8$%*xn!mdDOwPG;V@-_S<8(C=%0JIWXQ;w8`eGf+O^PeJ*xKN01Ae;mr){%DjB z_y?if;U9?dPJcMcoBalqTl{L2*ZV2Tb<0s)y%fdeohU9`fMWSP6iXXXbk?J2nTcX< zEsBQeQ&7&RL@})dMfG?Tl?5m!=Asxk8pRkF3YP_iB@;!e7+S(G6tP2442nb%E{3Ah zqtL31C@FhWVqdd2cShtq#{zE-OH;GAB=!w^3l&FL;H~UbJ$puCKeCUoh|i#M9}7G) z)~#pHO6)bP?oaGlRPJZ|odZk}c)r})Ps#3**mvw*b_9!k7j9n90(Z@viu$`Hb~m=; zd3KO}kNO)};O>^GVQh!QK4b5&7unJe72z#MfOeN|PuhXG>}98v5g!+^E!**;;kf@Zsdbgbr`r zeBYcV?<`+?M@t+2^u0C&G8EB)65R}5|Dw(wxLs$_dS}gQ^Ud;h_yUJ?Ndc3-nCJru zn#jOAhJk@C`VSO%{kXuTh8*UP)&~}9?16ZVCUCE2Cea1@Ytx7kjS~XXwFv`tXsKfN zN?4jF*k1NIyO+I$*=}NidskQ0MX}3qpEUh%K<*lLx!LJ6dF=I0lfBXDGI_1eMpJ{^ zS8w(D>^7_0?o;cfYRQ(6qEIL_fy*`}b3>{=eaLXfb?4fnUX8B`^G~Qz1)E56lZQ4X ztF2~>&181*${P%WVw6$8g{c$P4$OS*qtE;aN`u^;KTd9Z4E

wVEF4}JEA>$b=Z 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 MQIcp*EsdD}pR?bOz5oCK delta 46332 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

{8)ylU#(U*qXY28OI8UaFgrTIY}< z=4L?IuN{+$zeLQI{w;B#+JYpqkzxpmv^#H|j;GTMkkPzY2`5A&c>XW+PNtfFISG&a z6~@aH)7{JnX}mu8m0k{YthqP(4?O)hGI_DXBoxx?pqVxETe^X%f9}hJc>A|Nhjr1R zHae*QVM$MO?{{<~Qy97O20Z9{Ag}D9QbmLeyA8`k#fSMH=wjxQwQH`z(|@4vjBFn` zM5-}lSSF*t=^Mbj;$t8za$hz6^<%oS|FXdnq8V!Rr%K(Q_6hyGHth7J-4xZp>DH>WTi%u>tb{&;#S z1GHR_);*xz;{Yny47@&(sbH#$_S}QNNrajjb)kU`eifT9VRkXbiIMa0^burw=Z};y zB!ZH@3S2*k*~2V&@4@?V%3x+oKSQe26zPog<>U7UGs~I!WAClN(}ys3hPTJ2boWJ? zTo7=HHm|K8lgyTt-=ppxM^N%*;IH~IQzGh;L*440Z~tKE|E>L*MkZ(Cl|N%X3>q&^ z>TXKPylQBc2dpE;Y|gI_=Qe(wF}=O36o&-8g` z=AHL_W@hZFZzl3WUs3$h=4$a>6^t;FO43&w9?2gV$wuE%&D5;iqrP7p%6HvF#*;Ru zn?K@(X3?b0gO#lQIm2PN^4|%ulgP8TvUwOK8xn+yZ`Lf(*e+UES?ZbIFt&syFb*$S zz9IbwpCgsA?)fLb-M{7luRM}eznyyqm|5Ib$#)pq*ELGl_Cfpm1os%t3-eL1S$?rf z4F(EnPzEWauJRb9u0|UBzzcIkPo=k3 zvg#$eQiwVd>L<*&pC^(W%k;ffUwgU=*4+d?Jc;D{wr!=v$wC~@dIgIws0STMM>1v1 zpzXY{BYI+%hAG*15!t&IEPE{ZoV0VS8^fQ+qAv?tDk*hF09Qr#v?be!^+XTMs_jGy z+88B;c#(o@HnKX%S)vJBO!;L8YB)Vw2@op+xN5j8nw+AVd-gZ>^1>J~=e2PtnTX-+ znvB|qkXcsE$$8;-Je3p93vLNT?NdxO*%*$fCZLQ^kPXpqOi;C8(7j+pc@H zz=_oVU$c8xuptfz)4TqXEcJd0&2u zx4MUrV-c~Yh*a^*@I&`t9v3!wL~|SB_5TwV_g306a~q9ab6%tW4)@-cc!k)iO%F|5 zO+}`NQ4xmbC8J^UK({^f1N zuVGfZCRKzi7QcqNvHr<45nd0uh>z<+)63UfBLgJRFWixPYiBX=$@JuKgG z$4h8S6UuKMmFr7ohTd3vcH=pBOABoet?k8nL42?26Lf!yRy5br?W6B#e)o!$X4-D) z=irgrz6KxNXqs#8ZL6cLrry#i>v`=e{Tb_g^IGzm`G#m`wDK6uZsXs^{qhrIiT;e- z%ot|)S!*+_aktmlfpKUzcEXpG_0=nwc)w@7*Mt zAWh;e8lyb$5O?8XaN#I4oXD^9BB8Xyy^Zl5hoTbUd>*h)j;D&>n7iH5dS0?dj_2hI zQCYbIc`d-De>gO@5!Qz0115jNDBUeuM*7O3(r%3d_sZ6uIjys@PAWQWjm98}mP+8? z%p|j@W>eEYPVvH7LVudjNeQZKGhOOm{5W#NGJZhh2cGp3b4dd4HlF1B*gGn#t49}v z0#PI;r@zLNSTZ_)Rvq4U0(7p%NlGw}x-ST(%O)U^sEH($bTevK^TLTj=Ng=-^hc)p{$v-qykps4{Gd*qWhV^etHKvAr|9hCA-wqF|0KDA@TeWFFOgD+T-U!gmSwKhranpsK1Qf?@@NhP|51wrVc?XLfH-2P@x4 zu0aR4>8{ksqdq~G?|{KvZ!JdN+KKx{g19Ogs9859n2?$pm0#i*rwM%I3KB&|4xF$F zGbGvQH?pfz3UwnRN`V2M!1+otjtodQhVfdf&?#vdN`gXS+>F^&F>UDH#&)Jx@QROk z;oG8Gqq`^xDgsl(n3ebgk@JLQWP+v3{kVOeS}C8~zK-q2@N^}XQ0?9PS&3)9ijKPT zIw>KoC-|@Ah09SfXOA?c9Aew(A<$ylN#OTiB@v|VjjLVwOS4Iichgi)d6k~$&e>!n z_PjHatflE$$q_=U2X$1F9kX|;kWZV>~ z5e`BPPrNJ#`PM19H2S6CU#w)ERiJ1A3G$6Re7bhxxQuevNd>020HD|HSZ(|!a6Id% z0@n`$Xq-J`azsMc2`otkS_}dZpsjc>O#Y%bOH_gR0+8DDUn70ka{^0Hfr!BZxLV#- zyI}}rc*DkB+#h~IaRcl0-sgBJ^=}rZ0@tqrXf?@LnUVNzTZXq~+%b;yKvGfJ_W4Qi zmlm`3DiGEaKz!c9q~5!ig|c=kP;>|Y$q2~Ok+4$MRs|w*0YonHibxE!pa-05`RzKgY1fg>$N9tq<^>7#FBSt}I?W1>_E$J_K? zRDFvv6__i4h!I^@#lO{R6Kkmg5v>4N-#+oRuQhEEi&24n0`PhIG^hTu&?*+K0*Run zg5Mo_s%3eLF3j-)5Q!LVTND>(l@2|}>?%+c1;96C!0`HC1@>p4Ot@QjxDcP9TWdzd z?DYPdMXEql0Ad6_-Vz(#>(+j_6g)9(R}e!xH==>OU`%fY0);23I%uni*@1iFKOPju zz@KmfPpc85-aC=#t}{wtuC_wnCZY)Fv zqHF;CSL{e@Vt8o={uRI8`ZUjvfk5E~&I-WP zd-*4}XI%PZonpL-lRI(5jD!U76Z$| z4XhD)ytHRTyB39~vl)06Zea0k0D-SPHaQOVvayCL;J5=oTlHrX`FUX^1NXumW4I6> zVTW@(efB8>`@#)`TmaB=RLGZUgVru!;9t0b;{vFC`$9&*dq>YRFfiOe~35yUB-fQW!|%-{i*A3%h#AC~>t zUK72FfwJL_U=u^%dr$4D{)Jhc8E6~s9L}}~@@-i5*PZ4!igqzDH{1~tbJ2X_NR6MS z7GYp-xFej22=cXf4VmnHtza9I)$du6LB6qr*5zmB`|f2RaJa+&2j59~@loBXVM$C} zZd|iuJ%lKii6b%fpBy_b^si(MuB7~*U1ZC|DmzcklIv-&YPV@iwXs@BIxQ`e@}wpx z%}4IdmH7WOCyw9r#2*HOGZfEWI$Gl@#E3x=I6$O)HX1Z`+(2EVQ4aD8YEp1F`priE%&AHRcdS8TEmm(0iJOr1 zC3C(Y7t=5+`*}OP7yCExk6=z!FnHJu#PQ#l{ZkYFs#4~BK{QSY#5{I%p|?SEhdEW* z;C3gFc0a#=Cg=O3Kve1Aiv;33dW+Pc|FG$E2l_bb7 zqWzBZ@jV{SV@{1KJN66+C(c_H(mD+qu`(4nD}b0)4boHNnzdxpRbcf2$iF>rdZUiJ zU&&{$sKC;$h!OS8*B6tXF8+f}Q-P<*-M9Akhz$WfwxqGCDlpv-G3?*AI@zhH>?bxw z1?(*W*vy;z_-?y7olRDO^CE`j`P3+%=*f6#Q3Y1dLmn+JFa9%~^gqTXs=!hv@_71q zM5kpz1K0!=Xf39@x+hGHn(nH4%*K0w z<{dUx1wI!*+j?)d&gw<#u`w#}KorVw&^yD@A)T;N6<8dK7!m8kbK|dWti?)H;7bUe zZ~G^uO?u{k`35WY0Lz^yRO3EX10uRSo5x0bzVk{kxzu~N%Ywi=t(LG+DsaRbfW9*F zoasX195zw~!fF99?HvEN?bp&j*$5RFE|O|0+m7wGWGrTp3V7JT{c7oPTvyU^yvGE=}qlp*+{v?O+I+E zlI44V<-5@3jz3R*)cMKYcUT|KcYZ?geE${rXjP+K5 zn2!O}Z}#ZRpm*;7%6h3lg#a29U3k{4N5Mdrs{%!0a9F;&{9CQx$OG0>1x~I*3@zDz zPW$!X9M(exBG&_GsAmJ3cSx^fIVvz;0CDFoSM<6+LSoq}5U~M(_b)>${NH~nu`Cs6 zE%r28d2OZk%PZ?xrV2EB12Nd_^LNwl&;OZqSAi2^pvCxgIF&N9@@Ljf1uBJvn(B?3 zVhdc^iFH+h2SPhC2bGk1tsLH(WvD>ZTLNg^hkAYR^C;Fu1uDf}z;9CX1`W5iI>gdd zz$S_tu%uPDuv2AiS(*x*oP-!+r``0&i@04(1tKQ{XeX!a$zJ0KXDKQ$A3#{Ull%V6 z*<)X(SW={V8rwSSB1?(6qw&0v}d;TTD3Q0Vc? z)h$pq@Tv!y&p1v#AVL^<>k}l$cYI$ZfMVQGyiu@*9sC2<{!1s|*V&t$k;w~B3TsJJ zzQB&JXRIO)c?g1(u@r4w1@JiM_^SM zn5UXEJl<2q(*NKn8A0M7<POS;O z)T+2VZ=oNOL$GJewoR|%cRz#)t|V6pqS!NBhQhV`;inw--nJikVY%=whV}FmzlI{h zqjtlF^K>VPA$_YpyT$GAp_cV~D8Ym=##2kL?I=_5E)q!keB)Q2XY3O0%p4^{rTvxD z*1Yj1vV({*!FRn+vVCV3WGg9o9E!pksd!s9lR;E-^X{TEylOMqP(#XJX@)7mV}1R}$}pu!Q7F3G6Dx7}KazM= zeL902fBIDseBu;l)$v2s26cHVM;hs2vgK3L6>c}tjXse>l(GxnON~tMA~;KUu$eNn z`#Xc9{}=qy6wTUTuo6-n60d15F6}|HetVY$ks)L9rt;@^Ar#(&{;RnJ;Q?lLFm&#b zp+u|&y#IZOZQ9F9dM}vTU>N)f?XP( zFAZ<+F}1C|hWKr72vgetCDK1lZD}_E-?~A@kkBuk1RM$2y_`MzE9DUVtC-sO?(blW z6YB{N`X1$MTI4CG$GCb(7%)$NPeO4^!E3*c8RjW~0>o7bYC%7>)Ski7wF>d>R>7uR zFz(3=@S}v`tz)5*fhelW@YePiSweDte|(>xJtmyKSCQBWtRugY{FBpV@>2l4-Hm`{us3+Qt&?%htQP#bg7StoclK7*q7`>5bB1 z<569E^S6e_)|bTIfd?GGscEsw;(?!)SLH|^RY3xI^);Dd!3^d{ugVd8?-e;R&B5m+@6BKzQyg<|q6O#VeFwzl?u`w!@3Bg3*gLB9seN z4bKr&L?TgZWFB5M_BJV3-SoiLSjSgbVv136*2#l zqk@Ss1(^-6gUn!Q3MS~LR1VV;fL}D(9JVCouTU84V{>0o!$ZJfnL(!C;-@ghh_{p@(DpF0 z1QR6@VO7E5$6cb^guR5;g=l1RVIf&Q#lD$XKgBQDEP};fq02KI6Nzo9W-1p(hULFF z!o)8WRVZ}2kd`{0B@1+H}%jN|8i)?dg2>rq2<>q`AB zYl;4uZof5!cC-fQ7FizSl=w!=6x|g|mL6mYE07~I_N|)AvA0>w?|{4Y%x}urIm%U zh97EgjO8ylhB8tz>HPN~{!q;34aO+pJj*11y9FIWTJ>wUfrD{_^|4gRP*o?y`dAW) z_3`frI+TcQB_A3|bG$p1coK3g3!g>OrKBLI%Pd|PCD!)3#cE2fJ8K#T(GQ5&XYy}@ zXpWzCv=X2f78VyxNho}RLcw1Nrf-tCOSh--I?YhGR-Xp6Ln)Fm2fEt=khicG@>)CQllxwfu0qxs1ec+3!wRaQ${G+D_%kmMf7T2p``@S zGAj9;ecGHCz9e*u+~G>Xt;G^pQvk;f6rijg?PsadRjMnz;Cz4&Nc)tT%%?P=xN=`t zA?2E9fNC?<&{&^pdZ^b(Rtanv`KD>Bu0+37J4G;Vw4WN!8|Ui3lY8nyj0y4@!xPy; zCK+~1Yr(CtYmU(sUgfk}>o5$ZnV2k6#j~_ptJ}OaXfR%8Z53;ejuxw!g35q-)L{^4 zbQ@@zz9s^0u6SSJS0!ian5P`2rMVQL=@ckVW-|#b&Z)!~8 zHJa&;w0IBD;y?h6`_Kdo=1FNZwT2*L<^6w$if<7dmhKy9cRq0=QfZPz<9YE$YT&u) zXuc~=0SIaa?|zNw!HQXfzwzKtZZ-HW4k5h=fy2Y;G?j0z_OQ5+HdH`QMnystO1jXc z+@6N?Ts#{$vj+VMpL|o4@t@}@YuExu1 z-D3ICyvM|iAqJ*9rz7Nh`2a3{&DH#YDe-IHzE0V-w-D#|@t>G`>=v33V8z`&?(K!M z)Tvd5qnEa3yT*6Uri+NMP4W(NXpYZlr=$#Re*|z{7{%Am0qxv=`A{S;c#UqXGqbK! zjBkZ|{&&;A>@CMkKegb1T0|0ECdTu7OcnV2w@?HX^?hmNxhe6(_iXHF4q$}jby z`MxRBT_qDUkULSdasr>9k1K$MU6b$1r`dipUw*|^LIm{i`HRGeS)55nlFm+PB7c@i zH+q*$^CYP3O!@9Cx{wHSCr`+xIld#Os_E1?`uXC{=$aeZbR3Dh_G1%pTE%SaKgCsF zk)cvwP7~=~BFv!tlSEO^$^Tu?B-AtUvME%pCwMyiQj)0WBu}wC^%N`~KEEUCDJ-RY zS4X9u6V>v$nJXZCCGt6K=txW2q33fwsrPS+RV^+Ssh3Rfr0&^{E7Qa;u{4bct155P zmS(#sK5}!7?JetPmUqlEOg1dE@9Tb|ACi@Fb7&|=*>~n>r`1)27ldIygdyR^_D^_0 zIo;?BVX(Lw$0H2<`A+(QMHu`&#lu{hz@NQG&3<7{v#Y5PToDO_4yP+Op!R)t(P&H0 zb8+8!Vkw`icG13msZNutR*2mF6r3083RE$5O{|z%0STntL|G+_mH50rM%!>SKW30 zs{0Bo)54>`_phWmwZfeApVj5{R?+d6l->bho@!#DNZ?hglxixMx=CVida8La9*r~a zZB$b@9dNgrtD4HSZqh%h8D)X&z6K?g1m5^*IDhgg-DncihlK0;yiWc5c-<-4I?ZWS y9X>)C4QeW0{TM$g950SU;}z!tY@j`MKh15wC2jfZ;{OIa+&NAF diff --git a/util/migrate/backfill_checksums.py b/util/migrate/backfill_checksums.py new file mode 100644 index 000000000..42c1fed44 --- /dev/null +++ b/util/migrate/backfill_checksums.py @@ -0,0 +1,67 @@ +import logging +from app import storage as store +from data.database import ImageStorage, ImageStoragePlacement, ImageStorageLocation, JOIN_LEFT_OUTER +from digest import checksums + +logger = logging.getLogger(__name__) + +def _get_imagestorages_with_locations(query_modifier): + query = (ImageStoragePlacement + .select(ImageStoragePlacement, ImageStorage, ImageStorageLocation) + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage, JOIN_LEFT_OUTER)) + query = query_modifier(query) + + location_list = list(query) + + storages = {} + for location in location_list: + storage = location.storage + + if not storage.id in storages: + storages[storage.id] = storage + storage.locations = set() + else: + storage = storages[storage.id] + + storage.locations.add(location.location.name) + + return storages.values() + +def backfill_checksum(imagestorage_with_locations): + try: + json_data = store.get_content(imagestorage_with_locations.locations, store.image_json_path(imagestorage_with_locations.uuid)) + with store.stream_read_file(imagestorage_with_locations.locations, store.image_layer_path(imagestorage_with_locations.uuid)) as fp: + imagestorage_with_locations.checksum = 'sha256:{0}'.format(checksums.sha256_file(fp, json_data + '\n')) + imagestorage_with_locations.save() + except IOError as e: + if str(e).startswith("No such key"): + imagestorage_with_locations.checksum = 'unknown:{0}'.format(imagestorage_with_locations.uuid) + imagestorage_with_locations.save() + except: + logger.exception('exception when backfilling checksum of %s', imagestorage_with_locations.uuid) + +def backfill_checksums(): + logger.setLevel(logging.DEBUG) + + logger.debug('backfill_checksums: Starting') + logger.debug('backfill_checksums: This can be a LONG RUNNING OPERATION. Please wait!') + + def limit_to_empty_checksum(query): + return query.where(ImageStorage.checksum >> None, ImageStorage.uploading == False).limit(100) + + while True: + storages = _get_imagestorages_with_locations(limit_to_empty_checksum) + if len(storages) == 0: + logger.debug('backfill_checksums: Completed') + return + + for storage in storages: + backfill_checksum(storage) + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + logging.getLogger('peewee').setLevel(logging.CRITICAL) + logging.getLogger('boto').setLevel(logging.CRITICAL) + backfill_checksums() diff --git a/util/migrate/backfill_parent_id.py b/util/migrate/backfill_parent_id.py index 2a4e7b091..0d2540489 100644 --- a/util/migrate/backfill_parent_id.py +++ b/util/migrate/backfill_parent_id.py @@ -1,38 +1,46 @@ import logging - -from data.database import Image, ImageStorage, db, db_for_update +from data.database import Image, ImageStorage, db from app import app -from util.migrate import yield_random_entries - logger = logging.getLogger(__name__) - def backfill_parent_id(): logger.setLevel(logging.DEBUG) logger.debug('backfill_parent_id: Starting') logger.debug('backfill_parent_id: This can be a LONG RUNNING OPERATION. Please wait!') - def fetch_batch(): - return (Image - .select(Image.id, Image.ancestors) - .join(ImageStorage) - .where(Image.parent >> None, Image.ancestors != '/', - ImageStorage.uploading == False)) + # Check for any images without parent + has_images = bool(list(Image + .select(Image.id) + .join(ImageStorage) + .where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False) + .limit(1))) - for to_backfill in yield_random_entries(fetch_batch, 10000, 0.3): - with app.config['DB_TRANSACTION_FACTORY'](db): - try: - image = db_for_update(Image - .select() - .where(Image.id == to_backfill.id)).get() - image.parent = to_backfill.ancestors.split('/')[-2] - image.save() - except Image.DoesNotExist: - pass + if not has_images: + logger.debug('backfill_parent_id: No migration needed') + return - logger.debug('backfill_parent_id: Completed') + while True: + # Load the record from the DB. + batch_images_ids = list(Image + .select(Image.id) + .join(ImageStorage) + .where(Image.parent >> None, Image.ancestors != '/', ImageStorage.uploading == False) + .limit(100)) + + if len(batch_images_ids) == 0: + logger.debug('backfill_parent_id: Completed') + return + + for image_id in batch_images_ids: + with app.config['DB_TRANSACTION_FACTORY'](db): + try: + image = Image.select(Image.id, Image.ancestors).where(Image.id == image_id).get() + image.parent = image.ancestors.split('/')[-2] + image.save() + except Image.DoesNotExist: + pass if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) diff --git a/workers/securityworker.py b/workers/securityworker.py index 81402cd07..29fdd3dcf 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -33,6 +33,7 @@ def _get_image_to_export(version): images = (Image .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) + .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() @@ -54,13 +55,14 @@ def _get_image_to_export(version): images = (Image .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid) + .distinct() .from_(candidates) .order_by(db_random_func()) .tuples() .limit(BATCH_SIZE)) for image in images: - rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': image[3], 'parent_storage_uuid': image[4]}) + rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) # Re-shuffle, otherwise the images without parents will always be on the top random.shuffle(rimages) @@ -164,8 +166,7 @@ class SecurityWorker(Worker): 'TarSum': img['storage_checksum'], 'Path': uri } - - if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None: + if img['parent_docker_image_id'] is not None: request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] # Post request From d7ace69fe3957a49e319708f3a2f10d2e2db5e24 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 13 Oct 2015 18:14:52 -0400 Subject: [PATCH 02/36] Add a vulnerability_found event for notice when we detect a vuln Fixes #637 Note: This PR does *not* actually raise the event; it merely adds support for it --- .../versions/50925110da8c_add_event_specific_config.py | 1 - initdb.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/data/migrations/versions/50925110da8c_add_event_specific_config.py b/data/migrations/versions/50925110da8c_add_event_specific_config.py index 4a7672b70..8b67fe51a 100644 --- a/data/migrations/versions/50925110da8c_add_event_specific_config.py +++ b/data/migrations/versions/50925110da8c_add_event_specific_config.py @@ -14,7 +14,6 @@ from alembic import op import sqlalchemy as sa from util.migrate import UTF8LongText - def upgrade(tables): ### commands auto generated by Alembic - please adjust! ### op.add_column('repositorynotification', sa.Column('event_config_json', UTF8LongText, nullable=False)) diff --git a/initdb.py b/initdb.py index 8b095b002..bc474fd03 100644 --- a/initdb.py +++ b/initdb.py @@ -95,7 +95,7 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): for path_builder in paths: path = path_builder(new_image.storage.uuid) store.put_content('local_us', path, checksum) - + new_image.security_indexed = False new_image.security_indexed_engine = maxsize new_image.save() From 87c56d1caa4c3271a9c3774479c92aa6f0d09a53 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 23 Oct 2015 15:20:28 -0400 Subject: [PATCH 03/36] Add vulnerabilities and packages API to Quay Fixes #564 --- endpoints/api/sec.py | 111 ++++++++++++++++++++++++++++++++++++++ test/test_api_security.py | 1 - 2 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 endpoints/api/sec.py diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py new file mode 100644 index 000000000..e4e571414 --- /dev/null +++ b/endpoints/api/sec.py @@ -0,0 +1,111 @@ +""" List and manage repository vulnerabilities and other sec information. """ + +import logging +import features +import requests +import json + +from urlparse import urljoin +from app import app +from data import model +from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, + RepositoryParamResource, resource, nickname, show_if, parse_args, + query_param) + + +logger = logging.getLogger(__name__) + +def _call_security_api(relative_url, *args, **kwargs): + """ Issues an HTTP call to the sec API at the given relative URL. """ + url = urljoin(app.config['SECURITY_SCANNER']['ENDPOINT'], relative_url % args) + + client = app.config['HTTPCLIENT'] + timeout = app.config['SECURITY_SCANNER'].get('API_CALL_TIMEOUT', 1) + + logger.debug('Looking up sec information: %s', url) + + try: + response = client.get(url, params=kwargs, timeout=timeout) + except requests.exceptions.Timeout: + raise DownstreamIssue(payload=dict(message='API call timed out')) + except requests.exceptions.ConnectionError: + raise DownstreamIssue(payload=dict(message='Could not connect to downstream service')) + + if response.status_code == 404: + raise NotFound() + + try: + response_data = json.loads(response.text) + except ValueError: + raise DownstreamIssue(payload=dict(message='Non-json response from downstream service')) + + if response.status_code / 100 != 2: + logger.warning('Got %s status code to call %s: %s', response.status_code, url, + response.text) + raise DownstreamIssue(payload=dict(message=response_data['Message'])) + + return response_data + + +@show_if(features.SECURITY_SCANNER) +@resource('/v1/repository//tag//vulnerabilities') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('tag', 'The name of the tag') +class RepositoryTagVulnerabilities(RepositoryParamResource): + """ Operations for managing the vulnerabilities in a repository tag. """ + + @require_repo_read + @nickname('getRepoTagVulnerabilities') + @parse_args + @query_param('minimumPriority', 'Minimum vulnerability priority', type=str, + default='Low') + def get(self, args, namespace, repository, tag): + """ Fetches the vulnerabilities (if any) for a repository tag. """ + try: + tag_image = model.tag.get_tag_image(namespace, repository, tag) + except model.DataModelException: + raise NotFound() + + if not tag_image.security_indexed: + logger.debug('Image %s for tag %s under repository %s/%s not security indexed', + tag_image.docker_image_id, tag, namespace, repository) + return { + 'security_indexed': False + } + + data = _call_security_api('/layers/%s/vulnerabilities', tag_image.docker_image_id, + minimumPriority=args.minimumPriority) + + return { + 'security_indexed': True, + 'data': data, + } + + +@show_if(features.SECURITY_SCANNER) +@resource('/v1/repository//image//packages') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('imageid', 'The image ID') +class RepositoryImagePackages(RepositoryParamResource): + """ Operations for listing the packages added/removed in an image. """ + + @require_repo_read + @nickname('getRepoImagePackages') + def get(self, namespace, repository, imageid): + """ Fetches the packages added/removed in the given repo image. """ + repo_image = model.image.get_repo_image(namespace, repository, imageid) + if repo_image is None: + raise NotFound() + + if not repo_image.security_indexed: + return { + 'security_indexed': False + } + + data = _call_security_api('/layers/%s/packages', repo_image.docker_image_id) + + return { + 'security_indexed': True, + 'data': data, + } + diff --git a/test/test_api_security.py b/test/test_api_security.py index 11b33f71a..290008f2b 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -49,7 +49,6 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana SuperUserSendRecoveryEmail, ChangeLog, SuperUserOrganizationManagement, SuperUserOrganizationList, SuperUserAggregateLogs) - from endpoints.api.secscan import RepositoryImagePackages, RepositoryTagVulnerabilities From fb3d0fa27dd9d204da9bf4891439cf1555a0b009 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 26 Oct 2015 15:13:58 -0400 Subject: [PATCH 04/36] Add a SecEndpoint class and move all the cert and config handling in there --- endpoints/api/sec.py | 22 ++++++------------ util/sec/__init__.py | 0 util/sec/secendpoint.py | 51 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 util/sec/__init__.py create mode 100644 util/sec/secendpoint.py diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py index e4e571414..e4550f488 100644 --- a/endpoints/api/sec.py +++ b/endpoints/api/sec.py @@ -2,11 +2,10 @@ import logging import features -import requests import json +import requests -from urlparse import urljoin -from app import app +from app import sec_endpoint from data import model from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, RepositoryParamResource, resource, nickname, show_if, parse_args, @@ -15,17 +14,11 @@ from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_pa logger = logging.getLogger(__name__) + def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ - url = urljoin(app.config['SECURITY_SCANNER']['ENDPOINT'], relative_url % args) - - client = app.config['HTTPCLIENT'] - timeout = app.config['SECURITY_SCANNER'].get('API_CALL_TIMEOUT', 1) - - logger.debug('Looking up sec information: %s', url) - try: - response = client.get(url, params=kwargs, timeout=timeout) + response = sec_endpoint.call_api(relative_url, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: @@ -40,8 +33,7 @@ def _call_security_api(relative_url, *args, **kwargs): raise DownstreamIssue(payload=dict(message='Non-json response from downstream service')) if response.status_code / 100 != 2: - logger.warning('Got %s status code to call %s: %s', response.status_code, url, - response.text) + logger.warning('Got %s status code to call: %s', response.status_code, response.text) raise DownstreamIssue(payload=dict(message=response_data['Message'])) return response_data @@ -73,7 +65,7 @@ class RepositoryTagVulnerabilities(RepositoryParamResource): 'security_indexed': False } - data = _call_security_api('/layers/%s/vulnerabilities', tag_image.docker_image_id, + data = _call_security_api('layers/%s/vulnerabilities', tag_image.docker_image_id, minimumPriority=args.minimumPriority) return { @@ -102,7 +94,7 @@ class RepositoryImagePackages(RepositoryParamResource): 'security_indexed': False } - data = _call_security_api('/layers/%s/packages', repo_image.docker_image_id) + data = _call_security_api('layers/%s/packages/diff', repo_image.docker_image_id) return { 'security_indexed': True, diff --git a/util/sec/__init__.py b/util/sec/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/util/sec/secendpoint.py b/util/sec/secendpoint.py new file mode 100644 index 000000000..9e9e57413 --- /dev/null +++ b/util/sec/secendpoint.py @@ -0,0 +1,51 @@ +import features +import logging +import requests +import json + +from urlparse import urljoin + +logger = logging.getLogger(__name__) + +class SecEndpoint(object): + """ Helper class for talking to the Sec API. """ + def __init__(self, app, config_provider): + self.app = app + self.config_provider = config_provider + + if not features.SECURITY_SCANNER: + return + + self.security_config = app.config['SECURITY_SCANNER'] + + self.certificate = self._getfilepath('CA_CERTIFICATE_FILENAME') or False + self.public_key = self._getfilepath('PUBLIC_KEY_FILENAME') + self.private_key = self._getfilepath('PRIVATE_KEY_FILENAME') + + if self.public_key and self.private_key: + self.keys = (self.public_key, self.private_key) + else: + self.keys = None + + def _getfilepath(self, config_key): + security_config = self.security_config + + if config_key in security_config: + with self.config_provider.get_volume_file(security_config[config_key]) as f: + return f.name + + return None + + def call_api(self, relative_url, *args, **kwargs): + """ Issues an HTTP call to the sec API at the given relative URL. """ + security_config = self.security_config + api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' + url = urljoin(api_url, relative_url % args) + + client = self.app.config['HTTPCLIENT'] + timeout = security_config.get('API_CALL_TIMEOUT', 1) + logger.debug('Looking up sec information: %s', url) + + return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, + verify=self.certificate) + From 407eaae137ec4ccf4819d8faa1c81690c19fb546 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 27 Oct 2015 17:38:48 -0400 Subject: [PATCH 05/36] WIP: Towards sec demo --- data/model/image.py | 13 ++++++ data/model/tag.py | 10 ++++ endpoints/sec.py | 55 ++++++++++++++++++++++ web.py | 2 + workers/securityworker.py | 96 +++++++++++++++++++++++++++++++++------ workers/worker.py | 2 +- 6 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 endpoints/sec.py diff --git a/data/model/image.py b/data/model/image.py index 7b673ee2f..207887235 100644 --- a/data/model/image.py +++ b/data/model/image.py @@ -12,6 +12,19 @@ from data.database import (Image, Repository, ImageStoragePlacement, Namespace, logger = logging.getLogger(__name__) +def get_repository_images_recursive(docker_image_ids): + """ Returns a query matching the given docker image IDs, along with any which have the image IDs + as parents. + + Note: This is a DB intensive operation and should be used sparingly. + """ + inner_images = Image.select('%/' + Image.id + '/%').where(Image.docker_image_id << docker_image_ids) + + images = Image.select(Image.id).where(Image.docker_image_id << docker_image_ids) + recursive_images = Image.select(Image.id).where(Image.ancestors ** inner_images) + return recursive_images | images + + def get_parent_images(namespace_name, repository_name, image_obj): """ Returns a list of parent Image objects in chronilogical order. """ parents = image_obj.ancestors diff --git a/data/model/tag.py b/data/model/tag.py index cf6ddf3c6..535d6c533 100644 --- a/data/model/tag.py +++ b/data/model/tag.py @@ -12,6 +12,16 @@ def _tag_alive(query, now_ts=None): (RepositoryTag.lifetime_end_ts > now_ts)) +def get_matching_tags(docker_image_ids, *args): + """ Returns a query pointing to all tags that contain the given image(s). """ + return (RepositoryTag + .select(*args) + .distinct() + .join(Image) + .where(Image.id << image.get_repository_images_recursive(docker_image_ids), + RepositoryTag.lifetime_end_ts >> None)) + + def list_repository_tags(namespace_name, repository_name, include_hidden=False, include_storage=False): to_select = (RepositoryTag, Image) diff --git a/endpoints/sec.py b/endpoints/sec.py new file mode 100644 index 000000000..c8b0e342b --- /dev/null +++ b/endpoints/sec.py @@ -0,0 +1,55 @@ +import logging + +from flask import request, make_response, Blueprint +from data import model +from data.database import RepositoryNotification, Repository, ExternalNotificationEvent, RepositoryTag, Image +from endpoints.notificationhelper import spawn_notification +from collections import defaultdict + +logger = logging.getLogger(__name__) + +sec = Blueprint('sec', __name__) + +@sec.route('/notification', methods=['POST']) +def sec_notification(): + data = request.get_json() + print data + + # Find all tags that contain the layer(s) introducing the vulnerability. + # TODO: remove this check once fixed. + if not 'IntroducingLayersIDs' in data['Content']: + return make_response('Okay') + + layer_ids = data['Content']['IntroducingLayersIDs'] + tags = model.tag.get_matching_tags(layer_ids, RepositoryTag, Repository, Image) + + # For any repository that has a notification setup, issue a notification. + event = ExternalNotificationEvent.get(name='vulnerability_found') + + matching = (tags.switch(RepositoryTag) + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event)) + + repository_map = defaultdict(list) + + for tag in matching: + repository_map[tag.repository_id].append(tag) + + for repository_id in repository_map: + tags = repository_map[repository_id] + + # TODO(jschorr): Pull out the other metadata once added. + event_data = { + 'tags': [tag.name for tag in tags], + 'vulnerability': { + 'id': data['Name'], + 'description': 'Some description', + 'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE', + 'priority': 'Medium', + }, + } + + spawn_notification(tags[0].repository, 'vulnerability_found', event_data) + + return make_response('Okay') diff --git a/web.py b/web.py index 96457d5c9..5430e7b93 100644 --- a/web.py +++ b/web.py @@ -11,6 +11,7 @@ from endpoints.oauthlogin import oauthlogin from endpoints.githubtrigger import githubtrigger from endpoints.gitlabtrigger import gitlabtrigger from endpoints.bitbuckettrigger import bitbuckettrigger +from endpoints.sec import sec if os.environ.get('DEBUGLOG') == 'true': logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False) @@ -23,3 +24,4 @@ application.register_blueprint(bitbuckettrigger, url_prefix='/oauth1') application.register_blueprint(api_bp, url_prefix='/api') application.register_blueprint(webhooks, url_prefix='/webhooks') application.register_blueprint(realtime, url_prefix='/realtime') +application.register_blueprint(sec, url_prefix='/sec') diff --git a/workers/securityworker.py b/workers/securityworker.py index 29fdd3dcf..c6a940b4a 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -1,22 +1,29 @@ import logging +import logging.config + import requests import features import time import os import random +import json +from endpoints.notificationhelper import spawn_notification +from collections import defaultdict from sys import exc_info from peewee import JOIN_LEFT_OUTER -from app import app, storage, OVERRIDE_CONFIG_DIRECTORY +from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, sec_endpoint from workers.worker import Worker -from data.database import Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect +from data.database import (Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, + db_random_func, UseThenDisconnect, RepositoryTag, Repository, + ExternalNotificationEvent, RepositoryNotification) logger = logging.getLogger(__name__) BATCH_SIZE = 20 INDEXING_INTERVAL = 10 -API_METHOD_INSERT = '/layers' -API_METHOD_VERSION = '/versions/engine' +API_METHOD_INSERT = '/v1/layers' +API_METHOD_VERSION = '/v1/versions/engine' def _get_image_to_export(version): Parent = Image.alias() @@ -25,14 +32,14 @@ def _get_image_to_export(version): # Without parent candidates = (Image - .select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum) + .select(Image.id, Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum) .join(ImageStorage) .where(Image.security_indexed_engine < version, Image.parent >> None, ImageStorage.uploading == False, ImageStorage.checksum != '') .limit(BATCH_SIZE*10) .alias('candidates')) images = (Image - .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) + .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) .distinct() .from_(candidates) .order_by(db_random_func()) @@ -40,11 +47,11 @@ def _get_image_to_export(version): .limit(BATCH_SIZE)) for image in images: - rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) + rimages.append({'image_id': image[0], 'docker_image_id': image[1], 'storage_uuid': image[2], 'storage_checksum': image[3], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) # With analyzed parent candidates = (Image - .select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid')) + .select(Image.id, Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid')) .join(Parent, on=(Image.parent == Parent.id)) .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) .switch(Image) @@ -54,7 +61,7 @@ def _get_image_to_export(version): .alias('candidates')) images = (Image - .select(candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid) + .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid) .distinct() .from_(candidates) .order_by(db_random_func()) @@ -62,7 +69,7 @@ def _get_image_to_export(version): .limit(BATCH_SIZE)) for image in images: - rimages.append({'docker_image_id': image[0], 'storage_uuid': image[1], 'storage_checksum': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) + rimages.append({'image_id': image[0], 'docker_image_id': image[1], 'storage_uuid': image[2], 'storage_checksum': image[3], 'parent_docker_image_id': image[4], 'parent_storage_uuid': image[5]}) # Re-shuffle, otherwise the images without parents will always be on the top random.shuffle(rimages) @@ -134,15 +141,24 @@ class SecurityWorker(Worker): return True def _index_images(self): + logger.debug('Starting indexing') + with UseThenDisconnect(app.config): while True: + logger.debug('Looking up images to index') + # Get images to analyze try: images = _get_image_to_export(self._target_version) + if not images: + logger.debug('No more image to analyze') + return + except Image.DoesNotExist: logger.debug('No more image to analyze') return + logger.debug('Found images to index: %s', images) for img in images: # Get layer storage URL path = storage.image_layer_path(img['storage_uuid']) @@ -191,6 +207,62 @@ class SecurityWorker(Worker): logger.warning('An engine runs on version %d but the target version is %d') _update_image(img, True, api_version) logger.info('Layer ID %s : analyzed successfully', request['ID']) + + + # TODO(jschorr): Put this in a proper place, properly comment, unify with the + # callback code, etc. + try: + logger.debug('Loading vulnerabilities for layer %s', img['image_id']) + response = sec_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) + except requests.exceptions.Timeout: + logger.debug('Timeout when calling Sec') + continue + except requests.exceptions.ConnectionError: + logger.debug('Connection error when calling Sec') + continue + + logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, img['image_id']) + + if response.status_code == 404: + continue + + sec_data = json.loads(response.text) + logger.debug('Got response vulnerabilities for layer %s: %s', img['image_id'], sec_data) + + if not sec_data['Vulnerabilities']: + continue + + event = ExternalNotificationEvent.get(name='vulnerability_found') + matching = (RepositoryTag + .select(RepositoryTag, Repository) + .distinct() + .where(RepositoryTag.image_id == img['id']) + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event)) + + repository_map = defaultdict(list) + + for tag in matching: + repository_map[tag.repository_id].append(tag) + + for repository_id in repository_map: + tags = repository_map[repository_id] + + for vuln in sec_data['Vulnerabilities']: + event_data = { + 'tags': [tag.name for tag in tags], + 'vulnerability': { + 'id': vuln['ID'], + 'description': vuln['Description'], + 'link': vuln['Link'], + 'priority': vuln['Priority'], + }, + } + + spawn_notification(tags[0].repository, 'vulnerability_found', event_data) + + else: if 'Message' in jsonResponse: if 'OS and/or package manager are not supported' in jsonResponse['Message']: @@ -206,13 +278,11 @@ class SecurityWorker(Worker): return if __name__ == '__main__': - logging.getLogger('requests').setLevel(logging.WARNING) - logging.getLogger('apscheduler').setLevel(logging.CRITICAL) - if not features.SECURITY_SCANNER: logger.debug('Security scanner disabled; skipping') while True: time.sleep(100000) + logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False) worker = SecurityWorker() worker.start() diff --git a/workers/worker.py b/workers/worker.py index a9ea5d219..47dcaf9ef 100644 --- a/workers/worker.py +++ b/workers/worker.py @@ -61,7 +61,7 @@ class Worker(object): pass def start(self): - logging.config.fileConfig('conf/logging.conf', disable_existing_loggers=False) + logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False) if not app.config.get('SETUP_COMPLETE', False): logger.info('Product setup is not yet complete; skipping worker startup') From 7fa4fe08e7028cce3d673c591b3aae5aa6f5bdbe Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 14:33:29 -0400 Subject: [PATCH 06/36] Fix worker --- workers/securityworker.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/workers/securityworker.py b/workers/securityworker.py index c6a940b4a..f3c2cd5b0 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -222,7 +222,6 @@ class SecurityWorker(Worker): continue logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, img['image_id']) - if response.status_code == 404: continue @@ -236,10 +235,10 @@ class SecurityWorker(Worker): matching = (RepositoryTag .select(RepositoryTag, Repository) .distinct() - .where(RepositoryTag.image_id == img['id']) .join(Repository) .join(RepositoryNotification) - .where(RepositoryNotification.event == event)) + .where(RepositoryNotification.event == event, + RepositoryTag.image == img['image_id'])) repository_map = defaultdict(list) From 75dfec7875a82378f2e0a8d20afeba87007a46e5 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 14:33:41 -0400 Subject: [PATCH 07/36] Fix endpoint --- config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config.py b/config.py index 40d685706..3e03b951b 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ class DefaultConfig(object): # Security scanner FEATURE_SECURITY_SCANNER = False SECURITY_SCANNER = { - 'ENDPOINT': 'http://192.168.99.100:6060', + 'ENDPOINT': 'http://192.168.99.101:6060', 'ENGINE_VERSION_TARGET': 1, 'API_VERSION': 'v1', 'API_TIMEOUT_SECONDS': 10, From 8c144397e920cfc51a53700e6db6b64f4b521077 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 15:38:55 -0400 Subject: [PATCH 08/36] WIP: UI for QuaySec --- .../directives/repo-view/repo-panel-tags.css | 36 +++++++++++++ .../directives/repo-view/repo-panel-tags.html | 42 ++++++++++++++++ .../directives/repo-view/repo-panel-tags.js | 50 ++++++++++++++++++- static/js/pages/image-view.js | 13 +++++ static/partials/image-view.html | 37 ++++++++++++++ 5 files changed, 177 insertions(+), 1 deletion(-) diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 267daf9e9..fe9a60385 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -85,6 +85,42 @@ margin-right: 2px; } +.repo-panel-tags-element .fa-flag { + cursor: pointer; +} + +.repo-panel-tags-element .vuln-name { + +} + +.repo-panel-tags-element .vuln-description { + color: #ccc; + font-size: 10px; +} + +.repo-panel-tags-element .fa-flag.None { + color: #00CA00; +} + +.repo-panel-tags-element .fa-flag.Medium { + color: orange; +} + +.repo-panel-tags-element .fa-flag.High { + color: red; +} + +@keyframes flickerAnimation { /* flame pulses */ + 0% { opacity:1; } + 50% { opacity:0; } + 100% { opacity:1; } +} + +.repo-panel-tags-element .fa-flag.Critical { + color: red; + opacity:1; + animation: flickerAnimation 1s infinite; +} @media (max-width: 767px) { .repo-panel-tags-element .tag-span { diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 420f95f60..f5690d29b 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -86,6 +86,12 @@ style="min-width: 62px;"> Size + + + + + Unknown + + + + + + + +

+ + diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 95e365a56..fcc5d950e 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -18,7 +18,7 @@ angular.module('quay').directive('repoPanelTags', function () { 'getImages': '&getImages' }, - controller: function($scope, $element, $filter, $location, ApiService, UIService) { + controller: function($scope, $element, $filter, $location, ApiService, UIService, VulnerabilityService) { var orderBy = $filter('orderBy'); $scope.checkedTags = UIService.createCheckStateController([], 'name'); @@ -35,6 +35,7 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.tagActionHandler = null; $scope.showingHistory = false; $scope.tagsPerPage = 50; + $scope.tagVulnerabilities = {}; var setTagState = function() { if (!$scope.repository || !$scope.selectedTags) { return; } @@ -149,6 +150,53 @@ angular.module('quay').directive('repoPanelTags', function () { setTagState(); }); + $scope.loadTagVulnerabilities = function(tag, tagData) { + var params = { + 'tag': tag.name, + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + }; + + ApiService.getRepoTagVulnerabilities(null, params).then(function(resp) { + tagData.indexed = resp.security_indexed; + tagData.loading = false; + + if (resp.security_indexed) { + tagData.hasVulnerabilities = !!resp.data.Vulnerabilities.length; + tagData.vulnerabilities = resp.data.Vulnerabilities; + + var highest = null; + resp.data.Vulnerabilities.forEach(function(v) { + if (highest == null || + VulnerabilityService.LEVELS[v.Priority].index < VulnerabilityService.LEVELS[highest.Priority].index) { + highest = v; + } + }); + + tagData.highestVulnerability = highest; + } + }, function() { + tagData.loading = false; + tagData.hasError = true; + }); + }; + + $scope.getTagVulnerabilities = function(tag) { + if (!$scope.repository) { + return + } + + var tagName = tag.name; + if (!$scope.tagVulnerabilities[tagName]) { + $scope.tagVulnerabilities[tagName] = { + 'loading': true + }; + + $scope.loadTagVulnerabilities(tag, $scope.tagVulnerabilities[tagName]); + } + + return $scope.tagVulnerabilities[tagName]; + }; + $scope.clearSelectedTags = function() { $scope.checkedTags.setChecked([]); }; diff --git a/static/js/pages/image-view.js b/static/js/pages/image-view.js index d8d3adc22..d848440f2 100644 --- a/static/js/pages/image-view.js +++ b/static/js/pages/image-view.js @@ -40,6 +40,19 @@ loadImage(); loadRepository(); + $scope.downloadPackages = function() { + if ($scope.packagesResource) { return; } + + var params = { + 'repository': namespace + '/' + name, + 'imageid': imageid + }; + + $scope.packagesResource = ApiService.getRepoImagePackagesAsResource(params).get(function(packages) { + $scope.packages = packages; + }); + }; + $scope.downloadChanges = function() { if ($scope.changesResource) { return; } diff --git a/static/partials/image-view.html b/static/partials/image-view.html index 181938afc..3213ee7ac 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -25,6 +25,10 @@ tab-init="downloadChanges()"> + + +
@@ -53,6 +57,39 @@
+ + +
+
+

Image Packages

+
+
This image has not been indexed yet
+
+ Please try again in a few minutes. +
+
+
+
This image contains no recognized packages
+
+ Quay currently indexes Debian, Red Hat and Ubuntu packages. +
+
+ + + + + + + + + + + + + +
Package NamePackage VersionOS
{{ package.Name }}{{ package.Version }}{{ package.OS }}
+
+
From 136ab28f17727defbfbe8d32b9581b6b712caa24 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 16:24:44 -0400 Subject: [PATCH 09/36] Base demo DB --- test/data/test.db | Bin 376832 -> 901120 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/data/test.db b/test/data/test.db index 6b318ccd35bcfe1ce60d788a341d160f7a773de7..85fd5f0a1ed530b6a3396875548a22abc32e51fc 100644 GIT binary patch delta 38957 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 Date: Wed, 28 Oct 2015 16:26:19 -0400 Subject: [PATCH 10/36] Base DB with notification --- test/data/test.db | Bin 901120 -> 901120 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/data/test.db b/test/data/test.db index 85fd5f0a1ed530b6a3396875548a22abc32e51fc..5a438c4de776d5e74874fad754552454be6bbf67 100644 GIT binary patch delta 1462 zcma))+lw1j9LMLD%h`73%zDGpT^puKDduu!GLuPS#f@95t=Ls@tMs8emrR=JCYd#p zM3xwm6oOAuMPb0V(z+tDxLC3SQmIAgt4RNVMPEdp7E8AvMLm<1lCB_OI50Ekd%k?n z_xI`b)Lu{h=pZS4JTp%UeQltT1C1JJ^gv?^+F z_MfJvh((elkMy5Es+0^_7ELKp6eTqw8;X%IvPCl?>$xJzA~k0!78MSkbls*EJK;2I z#hBCWTErg;S{)(ee2= zPt8kGs{4*rK7Qss^HsynUTCI`%t>)^c|ol2!&&ci>w`+r42%yyURj^PCHN_xe$I(sg^sYMyyXTE3d!bq56XcB}s zd*N&FwTT9HgkA)bG`6eh-AbmQ9972HfvjMK17wy8I4r?zB|hBV0yg?Kk6U{-2DfkLZUsabX1uq$?Bb*U6k z8hBT&M%fuzj?lzJJqI5|e&#=sl{^xY85PM0O%S>JQ07U>4kAq+`Q4_zx-{O7&^gb6 ze+%!jl>gbE(F-Athie4<77pPC{0goOIq)g;HXvZU;THXfcO8OPW_Y+xz@OkPcoR?f z9{dCT3V(sup%1@->;4Q6J{&*s!y!=Md3YtD;U2dA8h#0{_RMq+hW5RLO8nk3`UTYmw(P4nEe7n#E<4sp|1^&9IgoCc3RJH`k9 z6kQ4q25zw40jxk{#&KQG=!Bna4VZ(&&vwc!VXHt-&O%X1B%H sF)aLRhbJ(Iw46p0dc+OP$t;7XNZS#w4OqNXYYJD9RHS6FnI6~w0Opfo)Bpeg From b408cfd2cc80e48a99a6cf3250d82443a616ca10 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 16:32:46 -0400 Subject: [PATCH 11/36] Ready for demo --- data/model/image.py | 5 +- endpoints/api/sec.py | 8 +- endpoints/sec.py | 7 +- .../directives/repo-view/repo-panel-tags.css | 7 +- .../directives/repo-view/repo-panel-tags.html | 2 +- static/partials/image-view.html | 6 +- workers/securityworker.py | 139 +++++++++--------- 7 files changed, 94 insertions(+), 80 deletions(-) diff --git a/data/model/image.py b/data/model/image.py index 207887235..8fcddc032 100644 --- a/data/model/image.py +++ b/data/model/image.py @@ -1,7 +1,7 @@ import logging import dateutil.parser -from peewee import JOIN_LEFT_OUTER, fn +from peewee import JOIN_LEFT_OUTER, fn, SQL from datetime import datetime from data.model import DataModelException, db_transaction, _basequery, storage @@ -18,7 +18,8 @@ def get_repository_images_recursive(docker_image_ids): Note: This is a DB intensive operation and should be used sparingly. """ - inner_images = Image.select('%/' + Image.id + '/%').where(Image.docker_image_id << docker_image_ids) + # TODO: test this on MySQL and Postgres + inner_images = Image.select(SQL('"%/" || id || "/%"')).where(Image.docker_image_id << docker_image_ids) images = Image.select(Image.id).where(Image.docker_image_id << docker_image_ids) recursive_images = Image.select(Image.id).where(Image.ancestors ** inner_images) diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py index e4550f488..a0703b6f5 100644 --- a/endpoints/api/sec.py +++ b/endpoints/api/sec.py @@ -65,7 +65,8 @@ class RepositoryTagVulnerabilities(RepositoryParamResource): 'security_indexed': False } - data = _call_security_api('layers/%s/vulnerabilities', tag_image.docker_image_id, + data = _call_security_api('layers/%s.%s/vulnerabilities', tag_image.docker_image_id, + tag_image.storage.uuid, minimumPriority=args.minimumPriority) return { @@ -79,7 +80,7 @@ class RepositoryTagVulnerabilities(RepositoryParamResource): @path_param('repository', 'The full path of the repository. e.g. namespace/name') @path_param('imageid', 'The image ID') class RepositoryImagePackages(RepositoryParamResource): - """ Operations for listing the packages added/removed in an image. """ + """ Operations for listing the packages in an image. """ @require_repo_read @nickname('getRepoImagePackages') @@ -94,7 +95,8 @@ class RepositoryImagePackages(RepositoryParamResource): 'security_indexed': False } - data = _call_security_api('layers/%s/packages/diff', repo_image.docker_image_id) + data = _call_security_api('layers/%s.%s/packages', repo_image.docker_image_id, + repo_image.storage.uuid) return { 'security_indexed': True, diff --git a/endpoints/sec.py b/endpoints/sec.py index c8b0e342b..367e77fd0 100644 --- a/endpoints/sec.py +++ b/endpoints/sec.py @@ -13,14 +13,17 @@ sec = Blueprint('sec', __name__) @sec.route('/notification', methods=['POST']) def sec_notification(): data = request.get_json() - print data # Find all tags that contain the layer(s) introducing the vulnerability. # TODO: remove this check once fixed. if not 'IntroducingLayersIDs' in data['Content']: return make_response('Okay') - layer_ids = data['Content']['IntroducingLayersIDs'] + # TODO: fix this for the image_id.storage thing properly. + layer_ids = [full_id.split('.')[0] for full_id in data['Content']['IntroducingLayersIDs']] + if not layer_ids: + return make_response('Okay') + tags = model.tag.get_matching_tags(layer_ids, RepositoryTag, Repository, Image) # For any repository that has a notification setup, issue a notification. diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index fe9a60385..6bc6975e1 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -94,8 +94,9 @@ } .repo-panel-tags-element .vuln-description { - color: #ccc; + color: #aaa; font-size: 10px; + white-space: normal; } .repo-panel-tags-element .fa-flag.None { @@ -110,6 +111,10 @@ color: red; } +.repo-panel-tags-element .vuln-dropdown ul { + min-width: 400px; +} + @keyframes flickerAnimation { /* flame pulses */ 0% { opacity:1; } 50% { opacity:0; } diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index f5690d29b..7d369f3f9 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -126,7 +126,7 @@ data-title="Image has no vulnerabilities" bs-tooltip> - -
+
This image contains no recognized packages
Quay currently indexes Debian, Red Hat and Ubuntu packages.
- +
- + diff --git a/workers/securityworker.py b/workers/securityworker.py index f3c2cd5b0..5a377ec95 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -97,13 +97,13 @@ def _update_image(image, indexed, version): .where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid'])) updated_images = list() - for image in query: - updated_images.append(image.id) + for row in query: + updated_images.append(row.id) - query = (Image + (Image .update(security_indexed=indexed, security_indexed_engine=version) - .where(Image.id << updated_images)) - query.execute() + .where(Image.id << updated_images) + .execute()) class SecurityWorker(Worker): def __init__(self): @@ -163,14 +163,17 @@ class SecurityWorker(Worker): # Get layer storage URL path = storage.image_layer_path(img['storage_uuid']) locations = self._default_storage_locations + if not storage.exists(locations, path): locations = _get_storage_locations(img['storage_uuid']) + if not storage.exists(locations, path): logger.warning('Could not find a valid location to download layer %s', img['docker_image_id']+'.'+img['storage_uuid']) # Mark as analyzed because that error is most likely to occur during the pre-process, with the database copy # when images are actually removed on the real database (and therefore in S3) _update_image(img, False, self._target_version) continue + uri = storage.get_direct_download_url(locations, path) if uri == None: # Local storage hack @@ -200,69 +203,7 @@ class SecurityWorker(Worker): logger.exception('An exception occurred when analyzing layer ID %s : the response is not valid JSON (%s)', request['ID'], httpResponse.text) return - if httpResponse.status_code == 201: - # The layer has been successfully indexed - api_version = jsonResponse['Version'] - if api_version < self._target_version: - logger.warning('An engine runs on version %d but the target version is %d') - _update_image(img, True, api_version) - logger.info('Layer ID %s : analyzed successfully', request['ID']) - - - # TODO(jschorr): Put this in a proper place, properly comment, unify with the - # callback code, etc. - try: - logger.debug('Loading vulnerabilities for layer %s', img['image_id']) - response = sec_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) - except requests.exceptions.Timeout: - logger.debug('Timeout when calling Sec') - continue - except requests.exceptions.ConnectionError: - logger.debug('Connection error when calling Sec') - continue - - logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, img['image_id']) - if response.status_code == 404: - continue - - sec_data = json.loads(response.text) - logger.debug('Got response vulnerabilities for layer %s: %s', img['image_id'], sec_data) - - if not sec_data['Vulnerabilities']: - continue - - event = ExternalNotificationEvent.get(name='vulnerability_found') - matching = (RepositoryTag - .select(RepositoryTag, Repository) - .distinct() - .join(Repository) - .join(RepositoryNotification) - .where(RepositoryNotification.event == event, - RepositoryTag.image == img['image_id'])) - - repository_map = defaultdict(list) - - for tag in matching: - repository_map[tag.repository_id].append(tag) - - for repository_id in repository_map: - tags = repository_map[repository_id] - - for vuln in sec_data['Vulnerabilities']: - event_data = { - 'tags': [tag.name for tag in tags], - 'vulnerability': { - 'id': vuln['ID'], - 'description': vuln['Description'], - 'link': vuln['Link'], - 'priority': vuln['Priority'], - }, - } - - spawn_notification(tags[0].repository, 'vulnerability_found', event_data) - - - else: + if httpResponse.status_code != 201: if 'Message' in jsonResponse: if 'OS and/or package manager are not supported' in jsonResponse['Message']: # The current engine could not index this layer @@ -276,6 +217,68 @@ class SecurityWorker(Worker): logger.exception('An exception occurred when analyzing layer ID %s : %d', request['ID'], httpResponse.status_code) return + # The layer has been successfully indexed + api_version = jsonResponse['Version'] + if api_version < self._target_version: + logger.warning('An engine runs on version %d but the target version is %d') + + logger.debug('Layer %s analyzed successfully', request['ID']) + _update_image(img, True, api_version) + + + # TODO(jschorr): Put this in a proper place, properly comment, unify with the + # callback code, etc. + try: + logger.debug('Loading vulnerabilities for layer %s', img['image_id']) + response = sec_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) + except requests.exceptions.Timeout: + logger.debug('Timeout when calling Sec') + continue + except requests.exceptions.ConnectionError: + logger.debug('Connection error when calling Sec') + continue + + logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, img['image_id']) + if response.status_code == 404: + continue + + sec_data = json.loads(response.text) + logger.debug('Got response vulnerabilities for layer %s: %s', img['image_id'], sec_data) + + if not sec_data['Vulnerabilities']: + continue + + event = ExternalNotificationEvent.get(name='vulnerability_found') + matching = (RepositoryTag + .select(RepositoryTag, Repository) + .distinct() + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event, + RepositoryTag.image == img['image_id'])) + + repository_map = defaultdict(list) + + for tag in matching: + repository_map[tag.repository_id].append(tag) + + for repository_id in repository_map: + tags = repository_map[repository_id] + + for vuln in sec_data['Vulnerabilities']: + event_data = { + 'tags': [tag.name for tag in tags], + 'vulnerability': { + 'id': vuln['ID'], + 'description': vuln['Description'], + 'link': vuln['Link'], + 'priority': vuln['Priority'], + }, + } + + spawn_notification(tags[0].repository, 'vulnerability_found', event_data) + + if __name__ == '__main__': if not features.SECURITY_SCANNER: logger.debug('Security scanner disabled; skipping') From 02e2bef94371c872552cec88ff68e0433ccdefb4 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 28 Oct 2015 17:06:40 -0400 Subject: [PATCH 12/36] Fix hardcoded priority --- endpoints/sec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/endpoints/sec.py b/endpoints/sec.py index 367e77fd0..2548e615c 100644 --- a/endpoints/sec.py +++ b/endpoints/sec.py @@ -49,7 +49,7 @@ def sec_notification(): 'id': data['Name'], 'description': 'Some description', 'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE', - 'priority': 'Medium', + 'priority': 'High', }, } From 7dbe15e339100aaf0cfd4fc0354036f2e7bed04b Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Mon, 9 Nov 2015 14:31:24 -0500 Subject: [PATCH 13/36] Remove checksum from Clair's worker and adjust line length --- workers/securityworker.py | 126 +++++++++++++++++++++++--------------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/workers/securityworker.py b/workers/securityworker.py index 5a377ec95..82a35a5b7 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -32,44 +32,63 @@ def _get_image_to_export(version): # Without parent candidates = (Image - .select(Image.id, Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum) - .join(ImageStorage) - .where(Image.security_indexed_engine < version, Image.parent >> None, ImageStorage.uploading == False, ImageStorage.checksum != '') - .limit(BATCH_SIZE*10) - .alias('candidates')) + .select(Image.id, Image.docker_image_id, ImageStorage.uuid) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, + Image.parent >> None, + ImageStorage.uploading == False) + .limit(BATCH_SIZE*10) + .alias('candidates')) images = (Image - .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum) - .distinct() - .from_(candidates) - .order_by(db_random_func()) - .tuples() - .limit(BATCH_SIZE)) + .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid) + .from_(candidates) + .order_by(db_random_func()) + .tuples() + .limit(BATCH_SIZE)) for image in images: - rimages.append({'image_id': image[0], 'docker_image_id': image[1], 'storage_uuid': image[2], 'storage_checksum': image[3], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) + rimages.append({'image_id': image[0], + 'docker_image_id': image[0], + 'storage_uuid': image[1], + 'parent_docker_image_id': None, + 'parent_storage_uuid': None}) # With analyzed parent candidates = (Image - .select(Image.id, Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid')) - .join(Parent, on=(Image.parent == Parent.id)) - .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) - .switch(Image) - .join(ImageStorage) - .where(Image.security_indexed_engine < version, Parent.security_indexed == True, Parent.security_indexed_engine >= version, ImageStorage.uploading == False, ImageStorage.checksum != '') - .limit(BATCH_SIZE*10) - .alias('candidates')) + .select(Image.id, + Image.docker_image_id, + ImageStorage.uuid, + Parent.docker_image_id.alias('parent_docker_image_id'), + ParentImageStorage.uuid.alias('parent_storage_uuid')) + .join(Parent, on=(Image.parent == Parent.id)) + .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) + .switch(Image) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, + Parent.security_indexed == True, + Parent.security_indexed_engine >= version, + ImageStorage.uploading == False) + .limit(BATCH_SIZE*10) + .alias('candidates')) images = (Image - .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid, candidates.c.checksum, candidates.c.parent_docker_image_id, candidates.c.parent_storage_uuid) - .distinct() - .from_(candidates) - .order_by(db_random_func()) - .tuples() - .limit(BATCH_SIZE)) + .select(candidates.c.id, + candidates.c.docker_image_id, + candidates.c.uuid, + candidates.c.parent_docker_image_id, + candidates.c.parent_storage_uuid) + .from_(candidates) + .order_by(db_random_func()) + .tuples() + .limit(BATCH_SIZE)) for image in images: - rimages.append({'image_id': image[0], 'docker_image_id': image[1], 'storage_uuid': image[2], 'storage_checksum': image[3], 'parent_docker_image_id': image[4], 'parent_storage_uuid': image[5]}) + rimages.append({'image_id': image[0], + 'docker_image_id': image[1], + 'storage_uuid': image[2], + 'parent_docker_image_id': image[3], + 'parent_storage_uuid': image[4]}) # Re-shuffle, otherwise the images without parents will always be on the top random.shuffle(rimages) @@ -78,11 +97,11 @@ def _get_image_to_export(version): def _get_storage_locations(uuid): query = (ImageStoragePlacement - .select() - .join(ImageStorageLocation) - .switch(ImageStoragePlacement) - .join(ImageStorage, JOIN_LEFT_OUTER) - .where(ImageStorage.uuid == uuid)) + .select() + .join(ImageStorageLocation) + .switch(ImageStoragePlacement) + .join(ImageStorage, JOIN_LEFT_OUTER) + .where(ImageStorage.uuid == uuid)) locations = list() for location in query: @@ -92,9 +111,10 @@ def _get_storage_locations(uuid): def _update_image(image, indexed, version): query = (Image - .select() - .join(ImageStorage) - .where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid'])) + .select() + .join(ImageStorage) + .where(Image.docker_image_id == image['docker_image_id'], + ImageStorage.uuid == image['storage_uuid'])) updated_images = list() for row in query: @@ -115,18 +135,20 @@ class SecurityWorker(Worker): # Load configuration config = app.config.get('SECURITY_SCANNER') - if not config or not 'ENDPOINT' in config or not 'ENGINE_VERSION_TARGET' in config or not 'DISTRIBUTED_STORAGE_PREFERENCE' in app.config: + if (not config + or not 'ENDPOINT' in config or not 'ENGINE_VERSION_TARGET' in config + or not 'DISTRIBUTED_STORAGE_PREFERENCE' in app.config): logger.exception('No configuration found for the security worker') return False self._api = config['ENDPOINT'] self._target_version = config['ENGINE_VERSION_TARGET'] self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE'] - self._ca_verification = False + self._ca = False self._cert = None if 'CA_CERTIFICATE_FILENAME' in config: - self._ca_verification = os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['CA_CERTIFICATE_FILENAME']) - if not os.path.isfile(self._ca_verification): + self._ca = os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['CA_CERTIFICATE_FILENAME']) + if not os.path.isfile(self._ca): logger.exception('Could not find configured CA file') return False if 'PRIVATE_KEY_FILENAME' in config and 'PUBLIC_KEY_FILENAME' in config: @@ -168,9 +190,8 @@ class SecurityWorker(Worker): locations = _get_storage_locations(img['storage_uuid']) if not storage.exists(locations, path): - logger.warning('Could not find a valid location to download layer %s', img['docker_image_id']+'.'+img['storage_uuid']) - # Mark as analyzed because that error is most likely to occur during the pre-process, with the database copy - # when images are actually removed on the real database (and therefore in S3) + logger.warning('Could not find a valid location to download layer %s', + img['docker_image_id']+'.'+img['storage_uuid']) _update_image(img, False, self._target_version) continue @@ -182,10 +203,9 @@ class SecurityWorker(Worker): # Forge request request = { 'ID': img['docker_image_id']+'.'+img['storage_uuid'], - 'TarSum': img['storage_checksum'], 'Path': uri } - if img['parent_docker_image_id'] is not None: + if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None: request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] # Post request @@ -193,28 +213,34 @@ class SecurityWorker(Worker): logger.info('Analyzing %s', request['ID']) # Using invalid certificates doesn't return proper errors because of # https://github.com/shazow/urllib3/issues/556 - httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, cert=self._cert, verify=self._ca_verification) + httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, + cert=self._cert, verify=self._ca) except: - logger.exception('An exception occurred when analyzing layer ID %s : %s', request['ID'], exc_info()[0]) + logger.exception('An exception occurred when analyzing layer ID %s : %s', + request['ID'], exc_info()[0]) return try: jsonResponse = httpResponse.json() except: - logger.exception('An exception occurred when analyzing layer ID %s : the response is not valid JSON (%s)', request['ID'], httpResponse.text) + logger.exception('An exception occurred when analyzing layer ID %s : the response is \ + not valid JSON (%s)', request['ID'], httpResponse.text) return if httpResponse.status_code != 201: if 'Message' in jsonResponse: if 'OS and/or package manager are not supported' in jsonResponse['Message']: # The current engine could not index this layer - logger.warning('A warning event occurred when analyzing layer ID %s : %s', request['ID'], jsonResponse['Message']) + logger.warning('A warning event occurred when analyzing layer ID %s : %s', + request['ID'], jsonResponse['Message']) # Hopefully, there is no version lower than the target one running _update_image(img, False, self._target_version) else: - logger.exception('An exception occurred when analyzing layer ID %s : %d %s', request['ID'], httpResponse.status_code, jsonResponse['Message']) + logger.exception('An exception occurred when analyzing layer ID %s : %d %s', + request['ID'], httpResponse.status_code, jsonResponse['Message']) return else: - logger.exception('An exception occurred when analyzing layer ID %s : %d', request['ID'], httpResponse.status_code) + logger.exception('An exception occurred when analyzing layer ID %s : %d', + request['ID'], httpResponse.status_code) return # The layer has been successfully indexed From 16c364a90c4220c704caf3b5d3503f27511998b7 Mon Sep 17 00:00:00 2001 From: Quentin Machu Date: Mon, 9 Nov 2015 15:14:25 -0500 Subject: [PATCH 14/36] Rename secscan_endpoint where required, fix index and indentation --- endpoints/api/sec.py | 4 ++-- workers/securityworker.py | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py index a0703b6f5..d080d2fe1 100644 --- a/endpoints/api/sec.py +++ b/endpoints/api/sec.py @@ -5,7 +5,7 @@ import features import json import requests -from app import sec_endpoint +from app import secscan_endpoint from data import model from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, RepositoryParamResource, resource, nickname, show_if, parse_args, @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: - response = sec_endpoint.call_api(relative_url, *args, **kwargs) + response = secscan_endpoint.call_api(relative_url, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: diff --git a/workers/securityworker.py b/workers/securityworker.py index 82a35a5b7..1deba196a 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -12,7 +12,7 @@ from endpoints.notificationhelper import spawn_notification from collections import defaultdict from sys import exc_info from peewee import JOIN_LEFT_OUTER -from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, sec_endpoint +from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_endpoint from workers.worker import Worker from data.database import (Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect, RepositoryTag, Repository, @@ -49,8 +49,8 @@ def _get_image_to_export(version): for image in images: rimages.append({'image_id': image[0], - 'docker_image_id': image[0], - 'storage_uuid': image[1], + 'docker_image_id': image[1], + 'storage_uuid': image[2], 'parent_docker_image_id': None, 'parent_storage_uuid': None}) @@ -102,7 +102,7 @@ def _get_storage_locations(uuid): .switch(ImageStoragePlacement) .join(ImageStorage, JOIN_LEFT_OUTER) .where(ImageStorage.uuid == uuid)) - + return query.get() locations = list() for location in query: locations.append(location.location.name) @@ -189,11 +189,11 @@ class SecurityWorker(Worker): if not storage.exists(locations, path): locations = _get_storage_locations(img['storage_uuid']) - if not storage.exists(locations, path): - logger.warning('Could not find a valid location to download layer %s', - img['docker_image_id']+'.'+img['storage_uuid']) - _update_image(img, False, self._target_version) - continue + if not storage.exists(locations, path): + logger.warning('Could not find a valid location to download layer %s', + img['docker_image_id']+'.'+img['storage_uuid']) + _update_image(img, False, self._target_version) + continue uri = storage.get_direct_download_url(locations, path) if uri == None: @@ -256,7 +256,7 @@ class SecurityWorker(Worker): # callback code, etc. try: logger.debug('Loading vulnerabilities for layer %s', img['image_id']) - response = sec_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) + response = secscan_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) except requests.exceptions.Timeout: logger.debug('Timeout when calling Sec') continue From a69c9e12fdb84ace59426f39f6f41206c6cc4a1b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 9 Nov 2015 17:12:22 -0500 Subject: [PATCH 15/36] Update quay sec code to fix problems identified in previous review - Change get_repository_images_recursive to operate over a single docker image and storage uuid - Move endpoints/sec to endpoints/secscan - Change notification system to work with new Quay-sec format Fixes #768 --- data/model/image.py | 25 ++++++---- data/model/tag.py | 11 +++-- endpoints/api/secscan.py | 2 +- endpoints/sec.py | 58 ---------------------- endpoints/secscan.py | 88 +++++++++++++++++++++++++++++++++ util/secscan/secscanendpoint.py | 37 ++++++++++++-- web.py | 4 +- 7 files changed, 146 insertions(+), 79 deletions(-) delete mode 100644 endpoints/sec.py create mode 100644 endpoints/secscan.py diff --git a/data/model/image.py b/data/model/image.py index 8fcddc032..9421e29ab 100644 --- a/data/model/image.py +++ b/data/model/image.py @@ -12,18 +12,23 @@ from data.database import (Image, Repository, ImageStoragePlacement, Namespace, logger = logging.getLogger(__name__) -def get_repository_images_recursive(docker_image_ids): - """ Returns a query matching the given docker image IDs, along with any which have the image IDs - as parents. - - Note: This is a DB intensive operation and should be used sparingly. +def get_repository_image_and_deriving(docker_image_id, storage_uuid): + """ Returns all matching images with the given docker image ID and storage uuid, along with any + images which have the image ID as parents. """ - # TODO: test this on MySQL and Postgres - inner_images = Image.select(SQL('"%/" || id || "/%"')).where(Image.docker_image_id << docker_image_ids) + try: + image_found = (Image + .select() + .join(ImageStorage) + .where(Image.docker_image_id == docker_image_id, + ImageStorage.uuid == storage_uuid) + .get()) + except Image.DoesNotExist: + return Image.select().where(Image.id < 0) # Empty query - images = Image.select(Image.id).where(Image.docker_image_id << docker_image_ids) - recursive_images = Image.select(Image.id).where(Image.ancestors ** inner_images) - return recursive_images | images + ancestors_pattern = '%s%s/%%' % (image_found.ancestors, image_found.id) + return Image.select().where((Image.ancestors ** ancestors_pattern) | + (Image.id == image_found.id)) def get_parent_images(namespace_name, repository_name, image_obj): diff --git a/data/model/tag.py b/data/model/tag.py index 535d6c533..fcaa7f342 100644 --- a/data/model/tag.py +++ b/data/model/tag.py @@ -12,14 +12,17 @@ def _tag_alive(query, now_ts=None): (RepositoryTag.lifetime_end_ts > now_ts)) -def get_matching_tags(docker_image_ids, *args): - """ Returns a query pointing to all tags that contain the given image(s). """ +def get_matching_tags(docker_image_id, storage_uuid, *args): + """ Returns a query pointing to all tags that contain the image with the + given docker_image_id and storage_uuid. """ + image_query = image.get_repository_image_and_deriving(docker_image_id, storage_uuid) + return (RepositoryTag .select(*args) .distinct() .join(Image) - .where(Image.id << image.get_repository_images_recursive(docker_image_ids), - RepositoryTag.lifetime_end_ts >> None)) + .join(ImageStorage) + .where(Image.id << image_query, RepositoryTag.lifetime_end_ts >> None)) def list_repository_tags(namespace_name, repository_name, include_hidden=False, diff --git a/endpoints/api/secscan.py b/endpoints/api/secscan.py index ab3f73051..9a1773ccb 100644 --- a/endpoints/api/secscan.py +++ b/endpoints/api/secscan.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: - response = secscan_endpoint.call_api(relative_url, *args, **kwargs) + response = secscan_endpoint.call_api(relative_url, body=None, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: diff --git a/endpoints/sec.py b/endpoints/sec.py deleted file mode 100644 index 2548e615c..000000000 --- a/endpoints/sec.py +++ /dev/null @@ -1,58 +0,0 @@ -import logging - -from flask import request, make_response, Blueprint -from data import model -from data.database import RepositoryNotification, Repository, ExternalNotificationEvent, RepositoryTag, Image -from endpoints.notificationhelper import spawn_notification -from collections import defaultdict - -logger = logging.getLogger(__name__) - -sec = Blueprint('sec', __name__) - -@sec.route('/notification', methods=['POST']) -def sec_notification(): - data = request.get_json() - - # Find all tags that contain the layer(s) introducing the vulnerability. - # TODO: remove this check once fixed. - if not 'IntroducingLayersIDs' in data['Content']: - return make_response('Okay') - - # TODO: fix this for the image_id.storage thing properly. - layer_ids = [full_id.split('.')[0] for full_id in data['Content']['IntroducingLayersIDs']] - if not layer_ids: - return make_response('Okay') - - tags = model.tag.get_matching_tags(layer_ids, RepositoryTag, Repository, Image) - - # For any repository that has a notification setup, issue a notification. - event = ExternalNotificationEvent.get(name='vulnerability_found') - - matching = (tags.switch(RepositoryTag) - .join(Repository) - .join(RepositoryNotification) - .where(RepositoryNotification.event == event)) - - repository_map = defaultdict(list) - - for tag in matching: - repository_map[tag.repository_id].append(tag) - - for repository_id in repository_map: - tags = repository_map[repository_id] - - # TODO(jschorr): Pull out the other metadata once added. - event_data = { - 'tags': [tag.name for tag in tags], - 'vulnerability': { - 'id': data['Name'], - 'description': 'Some description', - 'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE', - 'priority': 'High', - }, - } - - spawn_notification(tags[0].repository, 'vulnerability_found', event_data) - - return make_response('Okay') diff --git a/endpoints/secscan.py b/endpoints/secscan.py new file mode 100644 index 000000000..874aec243 --- /dev/null +++ b/endpoints/secscan.py @@ -0,0 +1,88 @@ +import logging +import features + +from app import secscan_endpoint +from flask import request, make_response, Blueprint +from data import model +from data.database import (RepositoryNotification, Repository, ExternalNotificationEvent, + RepositoryTag, Image, ImageStorage) +from endpoints.common import route_show_if +from endpoints.notificationhelper import spawn_notification +from collections import defaultdict + +logger = logging.getLogger(__name__) +secscan = Blueprint('secscan', __name__) + +@route_show_if(features.SECURITY_SCANNER) +@secscan.route('/notification', methods=['POST']) +def secscan_notification(): + data = request.get_json() + logger.debug('Got notification from Clair: %s', data) + + # Find all tags that contain the layer(s) introducing the vulnerability. + content = data['Content'] + layer_ids = content.get('NewIntroducingLayersIDs', content.get('IntroducingLayersIDs', [])) + if not layer_ids: + return make_response('Okay') + + # TODO(jzelinkskie): Write a queueitem for these layer ids, and do the rest of this + # in a worker. + cve_id = data['Name'] + vulnerability = data['Content']['Vulnerability'] + priority = vulnerability['Priority'] + + # Lookup the external event for when we have vulnerabilities. + event = ExternalNotificationEvent.get(name='vulnerability_found') + + # For each layer, retrieving the matching tags and join with repository to determine which + # require new notifications. + tag_map = defaultdict(set) + repository_map = {} + + for layer_id in layer_ids: + (docker_image_id, storage_uuid) = layer_id.split('.', 2) + tags = model.tag.get_matching_tags(docker_image_id, storage_uuid, RepositoryTag, + Repository, Image, ImageStorage) + + # Additionally filter to tags only in repositories that have the event setup. + matching = (tags.switch(RepositoryTag) + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event)) + + check_map = {} + for tag in matching: + # Verify that the tag's root image has the vulnerability. + tag_layer_id = '%s.%s' % (tag.image.docker_image_id, tag.image.storage.uuid) + logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id) + + if not tag_layer_id in check_map: + is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) + check_map[tag_layer_id] = is_vulerable + + logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id, + check_map[tag_layer_id]) + + if check_map[tag_layer_id]: + # Add the vulnerable tag to the list. + tag_map[tag.repository_id].add(tag.name) + repository_map[tag.repository_id] = tag.repository + + # For each of the tags found, issue a notification. + for repository_id in tag_map: + tags = tag_map[repository_id] + event_data = { + 'tags': list(tags), + 'vulnerability': { + 'id': data['Name'], + 'description': vulnerability['Description'], + 'link': vulnerability['Link'], + 'priority': priority, + }, + } + + # TODO: only add this notification if the repository's event(s) defined meet the priority + # minimum. + spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data) + + return make_response('Okay') diff --git a/util/secscan/secscanendpoint.py b/util/secscan/secscanendpoint.py index 7f759219b..2cd24fab1 100644 --- a/util/secscan/secscanendpoint.py +++ b/util/secscan/secscanendpoint.py @@ -1,7 +1,6 @@ import features import logging import requests -import json from urlparse import urljoin @@ -36,7 +35,33 @@ class SecurityScanEndpoint(object): return None - def call_api(self, relative_url, *args, **kwargs): + def check_layer_vulnerable(self, layer_id, cve_id): + """ Checks with Clair whether the given layer is vulnerable to the given CVE. """ + try: + body = { + 'LayersIDs': [layer_id] + } + response = self.call_api('vulnerabilities/%s/affected-layers', body, cve_id) + except requests.exceptions.RequestException: + logger.exception('Got exception when trying to call Clair endpoint') + return False + + if response.status_code != 200: + return False + + try: + response_data = response.json() + except ValueError: + logger.exception('Got exception when trying to parse Clair response') + return False + + if (not layer_id in response_data or + not response_data[layer_id].get('Vulnerable', False)): + return False + + return True + + def call_api(self, relative_url, body=None, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ security_config = self.security_config api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' @@ -46,5 +71,9 @@ class SecurityScanEndpoint(object): timeout = security_config.get('API_TIMEOUT_SECONDS', 1) logger.debug('Looking up sec information: %s', url) - return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) \ No newline at end of file + if body is not None: + return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self.keys, + verify=self.certificate) + else: + return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, + verify=self.certificate) \ No newline at end of file diff --git a/web.py b/web.py index 5430e7b93..445c2fa5b 100644 --- a/web.py +++ b/web.py @@ -11,7 +11,7 @@ from endpoints.oauthlogin import oauthlogin from endpoints.githubtrigger import githubtrigger from endpoints.gitlabtrigger import gitlabtrigger from endpoints.bitbuckettrigger import bitbuckettrigger -from endpoints.sec import sec +from endpoints.secscan import secscan if os.environ.get('DEBUGLOG') == 'true': logging.config.fileConfig('conf/logging_debug.conf', disable_existing_loggers=False) @@ -24,4 +24,4 @@ application.register_blueprint(bitbuckettrigger, url_prefix='/oauth1') application.register_blueprint(api_bp, url_prefix='/api') application.register_blueprint(webhooks, url_prefix='/webhooks') application.register_blueprint(realtime, url_prefix='/realtime') -application.register_blueprint(sec, url_prefix='/sec') +application.register_blueprint(secscan, url_prefix='/secscan') From dc476470fe282abbf5ca255fdf05bac241ab31e3 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Mon, 9 Nov 2015 18:30:14 -0500 Subject: [PATCH 16/36] add secscan notification queue --- app.py | 2 ++ config.py | 1 + 2 files changed, 3 insertions(+) diff --git a/app.py b/app.py index 01c4ae6ab..bbc8ecee1 100644 --- a/app.py +++ b/app.py @@ -148,6 +148,8 @@ image_replication_queue = WorkQueue(app.config['REPLICATION_QUEUE_NAME'], tf) dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf, reporter=MetricQueueReporter(metric_queue)) notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf) +secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf) + secscan_endpoint = SecurityScanEndpoint(app, config_provider) database.configure(app.config) diff --git a/config.py b/config.py index 3e03b951b..3629ecab4 100644 --- a/config.py +++ b/config.py @@ -131,6 +131,7 @@ class DefaultConfig(object): DIFFS_QUEUE_NAME = 'imagediff' DOCKERFILE_BUILD_QUEUE_NAME = 'dockerfilebuild' REPLICATION_QUEUE_NAME = 'imagestoragereplication' + SECSCAN_NOTIFICATION_QUEUE_NAME = 'secscan_notification' # Super user config. Note: This MUST BE an empty list for the default config. SUPER_USERS = [] From d651ea4b48ba08a209aa5c00a870f3ad86220a82 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Mon, 9 Nov 2015 19:17:15 -0500 Subject: [PATCH 17/36] initial security notification worker --- endpoints/secscan.py | 71 ++---------------- workers/security_notification_worker.py | 95 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 67 deletions(-) create mode 100644 workers/security_notification_worker.py diff --git a/endpoints/secscan.py b/endpoints/secscan.py index 874aec243..7576318e8 100644 --- a/endpoints/secscan.py +++ b/endpoints/secscan.py @@ -1,14 +1,11 @@ import logging +import json + import features -from app import secscan_endpoint +from app import secscan_notification_queue from flask import request, make_response, Blueprint -from data import model -from data.database import (RepositoryNotification, Repository, ExternalNotificationEvent, - RepositoryTag, Image, ImageStorage) from endpoints.common import route_show_if -from endpoints.notificationhelper import spawn_notification -from collections import defaultdict logger = logging.getLogger(__name__) secscan = Blueprint('secscan', __name__) @@ -19,70 +16,10 @@ def secscan_notification(): data = request.get_json() logger.debug('Got notification from Clair: %s', data) - # Find all tags that contain the layer(s) introducing the vulnerability. content = data['Content'] layer_ids = content.get('NewIntroducingLayersIDs', content.get('IntroducingLayersIDs', [])) if not layer_ids: return make_response('Okay') - # TODO(jzelinkskie): Write a queueitem for these layer ids, and do the rest of this - # in a worker. - cve_id = data['Name'] - vulnerability = data['Content']['Vulnerability'] - priority = vulnerability['Priority'] - - # Lookup the external event for when we have vulnerabilities. - event = ExternalNotificationEvent.get(name='vulnerability_found') - - # For each layer, retrieving the matching tags and join with repository to determine which - # require new notifications. - tag_map = defaultdict(set) - repository_map = {} - - for layer_id in layer_ids: - (docker_image_id, storage_uuid) = layer_id.split('.', 2) - tags = model.tag.get_matching_tags(docker_image_id, storage_uuid, RepositoryTag, - Repository, Image, ImageStorage) - - # Additionally filter to tags only in repositories that have the event setup. - matching = (tags.switch(RepositoryTag) - .join(Repository) - .join(RepositoryNotification) - .where(RepositoryNotification.event == event)) - - check_map = {} - for tag in matching: - # Verify that the tag's root image has the vulnerability. - tag_layer_id = '%s.%s' % (tag.image.docker_image_id, tag.image.storage.uuid) - logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id) - - if not tag_layer_id in check_map: - is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) - check_map[tag_layer_id] = is_vulerable - - logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id, - check_map[tag_layer_id]) - - if check_map[tag_layer_id]: - # Add the vulnerable tag to the list. - tag_map[tag.repository_id].add(tag.name) - repository_map[tag.repository_id] = tag.repository - - # For each of the tags found, issue a notification. - for repository_id in tag_map: - tags = tag_map[repository_id] - event_data = { - 'tags': list(tags), - 'vulnerability': { - 'id': data['Name'], - 'description': vulnerability['Description'], - 'link': vulnerability['Link'], - 'priority': priority, - }, - } - - # TODO: only add this notification if the repository's event(s) defined meet the priority - # minimum. - spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data) - + secscan_notification_queue.put(data['Name'], json.dumps(data)) return make_response('Okay') diff --git a/workers/security_notification_worker.py b/workers/security_notification_worker.py new file mode 100644 index 000000000..2c89d4623 --- /dev/null +++ b/workers/security_notification_worker.py @@ -0,0 +1,95 @@ +import json +import logging +import time + +from collections import defaultdict + +import features + +from app import secscan_notification_queue, secscan_endpoint +from data import model +from data.database import (Image, ImageStorage, ExternalNotificationEvent, + Repository, RepositoryNotification, RepositoryTag) +from endpoints.notificationhelper import spawn_notification +from workers.queueworker import QueueWorker + + +logger = logging.getLogger(__name__) + + +class SecurityNotificationWorker(QueueWorker): + def process_queue_item(self, queueitem): + data = json.loads(queueitem.body) + + cve_id = data['Name'] + vulnerability = data['Content']['Vulnerability'] + priority = vulnerability['Priority'] + + # Lookup the external event for when we have vulnerabilities. + event = ExternalNotificationEvent.get(name='vulnerability_found') + + # For each layer, retrieving the matching tags and join with repository to determine which + # require new notifications. + tag_map = defaultdict(set) + repository_map = {} + + # Find all tags that contain the layer(s) introducing the vulnerability. + content = data['Content'] + layer_ids = content.get('NewIntroducingLayersIDs', content.get('IntroducingLayersIDs', [])) + for layer_id in layer_ids: + (docker_image_id, storage_uuid) = layer_id.split('.', 2) + tags = model.tag.get_matching_tags(docker_image_id, storage_uuid, RepositoryTag, + Repository, Image, ImageStorage) + + # Additionally filter to tags only in repositories that have the event setup. + matching = (tags + .switch(RepositoryTag) + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event)) + + check_map = {} + for tag in matching: + # Verify that the tag's root image has the vulnerability. + tag_layer_id = '%s.%s' % (tag.image.docker_image_id, tag.image.storage.uuid) + logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id) + + if not tag_layer_id in check_map: + is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) + check_map[tag_layer_id] = is_vulerable + + logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id, + check_map[tag_layer_id]) + + if check_map[tag_layer_id]: + # Add the vulnerable tag to the list. + tag_map[tag.repository_id].add(tag.name) + repository_map[tag.repository_id] = tag.repository + + # For each of the tags found, issue a notification. + for repository_id in tag_map: + tags = tag_map[repository_id] + event_data = { + 'tags': list(tags), + 'vulnerability': { + 'id': data['Name'], + 'description': vulnerability['Description'], + 'link': vulnerability['Link'], + 'priority': priority, + }, + } + + # TODO(jzelinskie): only add this notification if the repository's event(s) defined meet + # the priority minimum. + spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data) + + +if __name__ == '__main__': + if not features.SECURITY_SCANNER: + logger.debug('Security scanner disabled; skipping SecurityNotificationWorker') + while True: + time.sleep(100000) + + worker = SecurityNotificationWorker(secscan_notification_queue, poll_period_seconds=30, + reservation_seconds=30, retry_after_seconds=30) + worker.start() From 954d9884521146f65352b3b9f97a5ad37ac7ec9a Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Mon, 9 Nov 2015 19:19:18 -0500 Subject: [PATCH 18/36] pylint: ignore constant names and too many locals --- pylintrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylintrc b/pylintrc index e1d21d338..7cecd0de7 100644 --- a/pylintrc +++ b/pylintrc @@ -9,7 +9,7 @@ # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=missing-docstring +disable=missing-docstring,invalid-name,too-many-locals [TYPECHECK] From 52962b3732524eddb3bca8e11a6a37e9b5634976 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 10 Nov 2015 13:06:03 -0500 Subject: [PATCH 19/36] close db connections when calling out to clair --- workers/security_notification_worker.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/workers/security_notification_worker.py b/workers/security_notification_worker.py index 2c89d4623..8ec95f7d8 100644 --- a/workers/security_notification_worker.py +++ b/workers/security_notification_worker.py @@ -6,10 +6,10 @@ from collections import defaultdict import features -from app import secscan_notification_queue, secscan_endpoint +from app import app, secscan_notification_queue, secscan_endpoint from data import model from data.database import (Image, ImageStorage, ExternalNotificationEvent, - Repository, RepositoryNotification, RepositoryTag) + Repository, RepositoryNotification, RepositoryTag, CloseForLongOperation) from endpoints.notificationhelper import spawn_notification from workers.queueworker import QueueWorker @@ -55,8 +55,9 @@ class SecurityNotificationWorker(QueueWorker): logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id) if not tag_layer_id in check_map: - is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) - check_map[tag_layer_id] = is_vulerable + with CloseForLongOperation(app.config): + is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) + check_map[tag_layer_id] = is_vulerable logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id, check_map[tag_layer_id]) @@ -79,7 +80,7 @@ class SecurityNotificationWorker(QueueWorker): }, } - # TODO(jzelinskie): only add this notification if the repository's event(s) defined meet + # TODO(jschorr): only add this notification if the repository's event(s) defined meet # the priority minimum. spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data) From da31714fb56c32a04a8b848f1013f1c2214a5d6d Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 10 Nov 2015 13:07:47 -0500 Subject: [PATCH 20/36] specify securityworker skip message --- workers/securityworker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workers/securityworker.py b/workers/securityworker.py index 1deba196a..65d71a8fe 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -307,7 +307,7 @@ class SecurityWorker(Worker): if __name__ == '__main__': if not features.SECURITY_SCANNER: - logger.debug('Security scanner disabled; skipping') + logger.debug('Security scanner disabled; skipping SecurityWorker') while True: time.sleep(100000) From 270010105d59c31230bd35c9ab552dc92261e144 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 10 Nov 2015 13:23:16 -0500 Subject: [PATCH 21/36] add security notification worker to init --- conf/init/service/security_notification_worker/log/run | 2 ++ conf/init/service/security_notification_worker/run | 8 ++++++++ 2 files changed, 10 insertions(+) create mode 100644 conf/init/service/security_notification_worker/log/run create mode 100644 conf/init/service/security_notification_worker/run diff --git a/conf/init/service/security_notification_worker/log/run b/conf/init/service/security_notification_worker/log/run new file mode 100644 index 000000000..262fed98e --- /dev/null +++ b/conf/init/service/security_notification_worker/log/run @@ -0,0 +1,2 @@ +#!/bin/sh +exec logger -i -t securitynotificationworker diff --git a/conf/init/service/security_notification_worker/run b/conf/init/service/security_notification_worker/run new file mode 100644 index 000000000..83c94e686 --- /dev/null +++ b/conf/init/service/security_notification_worker/run @@ -0,0 +1,8 @@ +#! /bin/bash + +echo 'Starting security scanner notification worker' + +cd / +venv/bin/python -m workers.security_notification_worker 2>&1 + +echo 'Security scanner notification worker exited' From 8e2868737b095e56e5c7cd9c9147e6c9eec2ce85 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 10 Nov 2015 15:01:33 -0500 Subject: [PATCH 22/36] rename secscan_endpoint and move db close to API --- app.py | 4 ++-- endpoints/api/sec.py | 4 ++-- endpoints/api/secscan.py | 4 ++-- util/secscan/{secscanendpoint.py => api.py} | 26 +++++++++++++-------- workers/security_notification_worker.py | 21 +++++++---------- workers/securityworker.py | 4 ++-- 6 files changed, 33 insertions(+), 30 deletions(-) rename util/secscan/{secscanendpoint.py => api.py} (74%) diff --git a/app.py b/app.py index bbc8ecee1..964a79536 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,7 @@ from util.saas.metricqueue import MetricQueue from util.config.provider import get_config_provider from util.config.configutil import generate_secret_key from util.config.superusermanager import SuperUserManager -from util.secscan.secscanendpoint import SecurityScanEndpoint +from util.secscan.api import SecurityScannerAPI OVERRIDE_CONFIG_DIRECTORY = 'conf/stack/' OVERRIDE_CONFIG_YAML_FILENAME = 'conf/stack/config.yaml' @@ -150,7 +150,7 @@ dockerfile_build_queue = WorkQueue(app.config['DOCKERFILE_BUILD_QUEUE_NAME'], tf notification_queue = WorkQueue(app.config['NOTIFICATION_QUEUE_NAME'], tf) secscan_notification_queue = WorkQueue(app.config['SECSCAN_NOTIFICATION_QUEUE_NAME'], tf) -secscan_endpoint = SecurityScanEndpoint(app, config_provider) +secscan_api = SecurityScannerAPI(app, config_provider) database.configure(app.config) model.config.app_config = app.config diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py index d080d2fe1..4e77750f9 100644 --- a/endpoints/api/sec.py +++ b/endpoints/api/sec.py @@ -5,7 +5,7 @@ import features import json import requests -from app import secscan_endpoint +from app import secscan_api from data import model from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, RepositoryParamResource, resource, nickname, show_if, parse_args, @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: - response = secscan_endpoint.call_api(relative_url, *args, **kwargs) + response = secscan_api.call(relative_url, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: diff --git a/endpoints/api/secscan.py b/endpoints/api/secscan.py index 9a1773ccb..56fcea95d 100644 --- a/endpoints/api/secscan.py +++ b/endpoints/api/secscan.py @@ -5,7 +5,7 @@ import features import json import requests -from app import secscan_endpoint +from app import secscan_api from data import model from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, RepositoryParamResource, resource, nickname, show_if, parse_args, @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: - response = secscan_endpoint.call_api(relative_url, body=None, *args, **kwargs) + response = secscan_api.call(relative_url, body=None, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: diff --git a/util/secscan/secscanendpoint.py b/util/secscan/api.py similarity index 74% rename from util/secscan/secscanendpoint.py rename to util/secscan/api.py index 2cd24fab1..e03a19369 100644 --- a/util/secscan/secscanendpoint.py +++ b/util/secscan/api.py @@ -2,11 +2,13 @@ import features import logging import requests +from app import app +from database import CloseForLongOperation from urlparse import urljoin logger = logging.getLogger(__name__) -class SecurityScanEndpoint(object): +class SecurityScannerAPI(object): """ Helper class for talking to the Security Scan service (Clair). """ def __init__(self, app, config_provider): self.app = app @@ -41,7 +43,7 @@ class SecurityScanEndpoint(object): body = { 'LayersIDs': [layer_id] } - response = self.call_api('vulnerabilities/%s/affected-layers', body, cve_id) + response = self.call('vulnerabilities/%s/affected-layers', body, cve_id) except requests.exceptions.RequestException: logger.exception('Got exception when trying to call Clair endpoint') return False @@ -61,8 +63,11 @@ class SecurityScanEndpoint(object): return True - def call_api(self, relative_url, body=None, *args, **kwargs): - """ Issues an HTTP call to the sec API at the given relative URL. """ + def call(self, relative_url, body=None, *args, **kwargs): + """ Issues an HTTP call to the sec API at the given relative URL. + This function disconnects from the database while awaiting a response + from the API server. + """ security_config = self.security_config api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' url = urljoin(api_url, relative_url % args) @@ -71,9 +76,10 @@ class SecurityScanEndpoint(object): timeout = security_config.get('API_TIMEOUT_SECONDS', 1) logger.debug('Looking up sec information: %s', url) - if body is not None: - return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) - else: - return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) \ No newline at end of file + with CloseForLongOperation(app.config): + if body is not None: + return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self.keys, + verify=self.certificate) + else: + return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, + verify=self.certificate) diff --git a/workers/security_notification_worker.py b/workers/security_notification_worker.py index 8ec95f7d8..1679e80b6 100644 --- a/workers/security_notification_worker.py +++ b/workers/security_notification_worker.py @@ -6,10 +6,10 @@ from collections import defaultdict import features -from app import app, secscan_notification_queue, secscan_endpoint +from app import secscan_notification_queue, secscan_api from data import model from data.database import (Image, ImageStorage, ExternalNotificationEvent, - Repository, RepositoryNotification, RepositoryTag, CloseForLongOperation) + Repository, RepositoryNotification, RepositoryTag) from endpoints.notificationhelper import spawn_notification from workers.queueworker import QueueWorker @@ -42,11 +42,11 @@ class SecurityNotificationWorker(QueueWorker): Repository, Image, ImageStorage) # Additionally filter to tags only in repositories that have the event setup. - matching = (tags - .switch(RepositoryTag) - .join(Repository) - .join(RepositoryNotification) - .where(RepositoryNotification.event == event)) + matching = list(tags + .switch(RepositoryTag) + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event)) check_map = {} for tag in matching: @@ -55,9 +55,8 @@ class SecurityNotificationWorker(QueueWorker): logger.debug('Checking if layer %s is vulnerable to %s', tag_layer_id, cve_id) if not tag_layer_id in check_map: - with CloseForLongOperation(app.config): - is_vulerable = secscan_endpoint.check_layer_vulnerable(tag_layer_id, cve_id) - check_map[tag_layer_id] = is_vulerable + is_vulerable = secscan_api.check_layer_vulnerable(tag_layer_id, cve_id) + check_map[tag_layer_id] = is_vulerable logger.debug('Result of layer %s is vulnerable to %s check: %s', tag_layer_id, cve_id, check_map[tag_layer_id]) @@ -80,8 +79,6 @@ class SecurityNotificationWorker(QueueWorker): }, } - # TODO(jschorr): only add this notification if the repository's event(s) defined meet - # the priority minimum. spawn_notification(repository_map[repository_id], 'vulnerability_found', event_data) diff --git a/workers/securityworker.py b/workers/securityworker.py index 65d71a8fe..26d360754 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -12,7 +12,7 @@ from endpoints.notificationhelper import spawn_notification from collections import defaultdict from sys import exc_info from peewee import JOIN_LEFT_OUTER -from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_endpoint +from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_api from workers.worker import Worker from data.database import (Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect, RepositoryTag, Repository, @@ -256,7 +256,7 @@ class SecurityWorker(Worker): # callback code, etc. try: logger.debug('Loading vulnerabilities for layer %s', img['image_id']) - response = secscan_endpoint.call_api('layers/%s/vulnerabilities', request['ID']) + response = secscan_api.call('layers/%s/vulnerabilities', request['ID']) except requests.exceptions.Timeout: logger.debug('Timeout when calling Sec') continue From 5655c084679bf8c7e13955bcab07b1789c61caeb Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Tue, 10 Nov 2015 15:05:06 -0500 Subject: [PATCH 23/36] fix security worker service permissions --- conf/init/service/security_notification_worker/run | 0 conf/init/service/securityworker/run | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 conf/init/service/security_notification_worker/run mode change 100644 => 100755 conf/init/service/securityworker/run diff --git a/conf/init/service/security_notification_worker/run b/conf/init/service/security_notification_worker/run old mode 100644 new mode 100755 diff --git a/conf/init/service/securityworker/run b/conf/init/service/securityworker/run old mode 100644 new mode 100755 From ca7d736db2de56823f0f7af0266b8c9dae000992 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 10 Nov 2015 15:08:14 -0500 Subject: [PATCH 24/36] Only send vulnerability events if the minimum priority is gte to that specified Fixes #770 --- endpoints/api/repositorynotification.py | 9 +- endpoints/common.py | 2 + endpoints/notificationevent.py | 48 ++++++--- .../directives/ui/repository-events-table.css | 10 ++ .../directives/repository-events-table.html | 11 +++ .../directives/ui/repository-events-table.js | 12 +++ static/js/services/notification-service.js | 3 +- static/js/services/vulnerability-service.js | 84 +--------------- templates/base.html | 1 + util/sec/__init__.py | 0 util/sec/secendpoint.py | 51 ---------- util/secscan/api.py | 97 ++++++++++++++++++- workers/notificationworker.py | 3 +- 13 files changed, 175 insertions(+), 156 deletions(-) delete mode 100644 util/sec/__init__.py delete mode 100644 util/sec/secendpoint.py diff --git a/endpoints/api/repositorynotification.py b/endpoints/api/repositorynotification.py index 30c71cf54..538bbe25e 100644 --- a/endpoints/api/repositorynotification.py +++ b/endpoints/api/repositorynotification.py @@ -22,12 +22,19 @@ def notification_view(note): except: config = {} + event_config = {} + try: + event_config = json.loads(note.event_config_json) + except: + event_config = {} + return { 'uuid': note.uuid, 'event': note.event.name, 'method': note.method.name, 'config': config, 'title': note.title, + 'event_config': event_config, } @@ -160,7 +167,7 @@ class TestRepositoryNotification(RepositoryParamResource): raise NotFound() event_info = NotificationEvent.get_event(test_note.event.name) - sample_data = event_info.get_sample_data(repository=test_note.repository) + sample_data = event_info.get_sample_data(test_note) notification_data = build_notification_data(test_note, sample_data) notification_queue.put([test_note.repository.namespace_user.username, repository, test_note.event.name], json.dumps(notification_data)) diff --git a/endpoints/common.py b/endpoints/common.py index 7469c58be..fba900580 100644 --- a/endpoints/common.py +++ b/endpoints/common.py @@ -22,6 +22,7 @@ from werkzeug.routing import BaseConverter from functools import wraps from config import frontend_visible_config from external_libraries import get_external_javascript, get_external_css +from util.secscan.api import PRIORITY_LEVELS import features @@ -183,6 +184,7 @@ def render_page_template(name, **kwargs): config_set=json.dumps(frontend_visible_config(app.config)), oauth_set=json.dumps(get_oauth_config()), scope_set=json.dumps(scopes.app_scopes(app.config)), + vuln_priority_set=json.dumps(PRIORITY_LEVELS), mixpanel_key=app.config.get('MIXPANEL_KEY', ''), google_analytics_key=app.config.get('GOOGLE_ANALYTICS_KEY', ''), sentry_public_dsn=app.config.get('SENTRY_PUBLIC_DSN', ''), diff --git a/endpoints/notificationevent.py b/endpoints/notificationevent.py index ebd7e10b6..365b815d3 100644 --- a/endpoints/notificationevent.py +++ b/endpoints/notificationevent.py @@ -1,9 +1,11 @@ import logging import time +import json from datetime import datetime from notificationhelper import build_event_data from util.jinjautil import get_template_env +from util.secscan.api import PRIORITY_LEVELS, get_priority_for_index template_env = get_template_env("events") logger = logging.getLogger(__name__) @@ -37,13 +39,18 @@ class NotificationEvent(object): 'notification_data': notification_data }) - def get_sample_data(self, repository=None): + def get_sample_data(self, notification): """ - Returns sample data for testing the raising of this notification, with an optional - repository. + Returns sample data for testing the raising of this notification, with an example notification. """ raise NotImplementedError + def should_perform(self, event_data, notification_data): + """ + Whether a notification for this event should be performed. By default returns True. + """ + return True + @classmethod def event_name(cls): """ @@ -71,8 +78,8 @@ class RepoPushEvent(NotificationEvent): def get_summary(self, event_data, notification_data): return 'Repository %s updated' % (event_data['repository']) - def get_sample_data(self, repository): - return build_event_data(repository, { + def get_sample_data(self, notification): + return build_event_data(notification.repository, { 'updated_tags': {'latest': 'someimageid', 'foo': 'anotherimage'}, 'pruned_image_count': 3 }) @@ -99,18 +106,27 @@ class VulnerabilityFoundEvent(NotificationEvent): return 'info' - def get_sample_data(self, repository): - return build_event_data(repository, { + def get_sample_data(self, notification): + event_config = json.loads(notification.event_config_json) + + return build_event_data(notification.repository, { 'tags': ['latest', 'prod'], 'image': 'some-image-id', 'vulnerability': { 'id': 'CVE-FAKE-CVE', 'description': 'A futurist vulnerability', 'link': 'https://security-tracker.debian.org/tracker/CVE-FAKE-CVE', - 'priority': 'Critical', + 'priority': get_priority_for_index(event_config['level']) }, }) + def should_perform(self, event_data, notification_data): + event_config = json.loads(notification_data.event_config_json) + expected_level_index = event_config['level'] + priority = PRIORITY_LEVELS[event_data['vulnerability']['priority']] + actual_level_index = priority['index'] + return expected_level_index <= actual_level_index + def get_summary(self, event_data, notification_data): msg = '%s vulnerability detected in repository %s in tags %s' return msg % (event_data['vulnerability']['priority'], @@ -126,10 +142,10 @@ class BuildQueueEvent(NotificationEvent): def get_level(self, event_data, notification_data): return 'info' - def get_sample_data(self, repository): + def get_sample_data(self, notification): build_uuid = 'fake-build-id' - return build_event_data(repository, { + return build_event_data(notification.repository, { 'is_manual': False, 'build_id': build_uuid, 'build_name': 'some-fake-build', @@ -165,10 +181,10 @@ class BuildStartEvent(NotificationEvent): def get_level(self, event_data, notification_data): return 'info' - def get_sample_data(self, repository): + def get_sample_data(self, notification): build_uuid = 'fake-build-id' - return build_event_data(repository, { + return build_event_data(notification.repository, { 'build_id': build_uuid, 'build_name': 'some-fake-build', 'docker_tags': ['latest', 'foo', 'bar'], @@ -193,10 +209,10 @@ class BuildSuccessEvent(NotificationEvent): def get_level(self, event_data, notification_data): return 'success' - def get_sample_data(self, repository): + def get_sample_data(self, notification): build_uuid = 'fake-build-id' - return build_event_data(repository, { + return build_event_data(notification.repository, { 'build_id': build_uuid, 'build_name': 'some-fake-build', 'docker_tags': ['latest', 'foo', 'bar'], @@ -222,10 +238,10 @@ class BuildFailureEvent(NotificationEvent): def get_level(self, event_data, notification_data): return 'error' - def get_sample_data(self, repository): + def get_sample_data(self, notification): build_uuid = 'fake-build-id' - return build_event_data(repository, { + return build_event_data(notification.repository, { 'build_id': build_uuid, 'build_name': 'some-fake-build', 'docker_tags': ['latest', 'foo', 'bar'], diff --git a/static/css/directives/ui/repository-events-table.css b/static/css/directives/ui/repository-events-table.css index 82909d022..f471a997c 100644 --- a/static/css/directives/ui/repository-events-table.css +++ b/static/css/directives/ui/repository-events-table.css @@ -1,3 +1,13 @@ .repository-events-table-element .notification-row i.fa { margin-right: 6px; +} + +.repository-events-table-element .notification-event-fields { + list-style: none; + padding: 0px; + margin-left: 28px; + margin-top: 3px; + font-size: 13px; + color: #888; + margin-bottom: 0px; } \ No newline at end of file diff --git a/static/directives/repository-events-table.html b/static/directives/repository-events-table.html index 3a463f54c..a83a45931 100644 --- a/static/directives/repository-events-table.html +++ b/static/directives/repository-events-table.html @@ -44,6 +44,17 @@ {{ getEventInfo(notification).title }} + +
    +
  • + {{ field.title }}: + + + {{ findEnumValue(field.values, notification.event_config[field.name]).title }} + + +
  • +
+ - - - + diff --git a/static/directives/vulnerability-priority-view.html b/static/directives/vulnerability-priority-view.html new file mode 100644 index 000000000..23bcce345 --- /dev/null +++ b/static/directives/vulnerability-priority-view.html @@ -0,0 +1,5 @@ + + + + {{ priority }} + \ No newline at end of file diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index fcc5d950e..6f80d193f 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -34,8 +34,8 @@ angular.module('quay').directive('repoPanelTags', function () { $scope.tagHistory = {}; $scope.tagActionHandler = null; $scope.showingHistory = false; - $scope.tagsPerPage = 50; - $scope.tagVulnerabilities = {}; + $scope.tagsPerPage = 25; + $scope.imageVulnerabilities = {}; var setTagState = function() { if (!$scope.repository || !$scope.selectedTags) { return; } @@ -57,7 +57,7 @@ angular.module('quay').directive('repoPanelTags', function () { allTags.push(tagInfo); - if (!$scope.options.tagFilter || tag.indexOf($scope.options.tagFilter) >= 0 || + if (!$scope.options.tagFilter || tagfOf($scope.options.tagFilter) >= 0 || tagInfo.image_id.indexOf($scope.options.tagFilter) >= 0) { tags.push(tagInfo); } @@ -150,51 +150,66 @@ angular.module('quay').directive('repoPanelTags', function () { setTagState(); }); - $scope.loadTagVulnerabilities = function(tag, tagData) { + $scope.loadImageVulnerabilities = function(image_id, imageData) { var params = { - 'tag': tag.name, + 'imageid': image_id, 'repository': $scope.repository.namespace + '/' + $scope.repository.name, }; - ApiService.getRepoTagVulnerabilities(null, params).then(function(resp) { - tagData.indexed = resp.security_indexed; - tagData.loading = false; + ApiService.getRepoImageVulnerabilities(null, params).then(function(resp) { + imageData.security_indexed = resp.security_indexed; + imageData.loading = false; - if (resp.security_indexed) { - tagData.hasVulnerabilities = !!resp.data.Vulnerabilities.length; - tagData.vulnerabilities = resp.data.Vulnerabilities; + if (imageData.security_indexed) { + var vulnerabilities = resp.data.Vulnerabilities; + + imageData.hasVulnerabilities = !!vulnerabilities.length; + imageData.vulnerabilities = vulnerabilities; + + var highest = { + 'Priority': 'Unknown', + 'Count': 0, + 'index': 100000 + }; - var highest = null; resp.data.Vulnerabilities.forEach(function(v) { - if (highest == null || - VulnerabilityService.LEVELS[v.Priority].index < VulnerabilityService.LEVELS[highest.Priority].index) { - highest = v; + if (VulnerabilityService.LEVELS[v.Priority].index < highest.index) { + highest = { + 'Priority': v.Priority, + 'Count': 1, + 'index': VulnerabilityService.LEVELS[v.Priority].index + } + } else if (VulnerabilityService.LEVELS[v.Priority].index == highest.index) { + highest['Count']++; } }); - tagData.highestVulnerability = highest; + imageData.highestVulnerability = highest; } }, function() { - tagData.loading = false; - tagData.hasError = true; + imageData.loading = false; + imageData.hasError = true; }); }; $scope.getTagVulnerabilities = function(tag) { + return $scope.getImageVulnerabilities(tag.image_id); + }; + + $scope.getImageVulnerabilities = function(image_id) { if (!$scope.repository) { return } - var tagName = tag.name; - if (!$scope.tagVulnerabilities[tagName]) { - $scope.tagVulnerabilities[tagName] = { + if (!$scope.imageVulnerabilities[image_id]) { + $scope.imageVulnerabilities[image_id] = { 'loading': true }; - $scope.loadTagVulnerabilities(tag, $scope.tagVulnerabilities[tagName]); + $scope.loadImageVulnerabilities(image_id, $scope.imageVulnerabilities[image_id]); } - return $scope.tagVulnerabilities[tagName]; + return $scope.imageVulnerabilities[image_id]; }; $scope.clearSelectedTags = function() { diff --git a/static/js/directives/ui/vulnerability-priority-view.js b/static/js/directives/ui/vulnerability-priority-view.js new file mode 100644 index 000000000..a257218f5 --- /dev/null +++ b/static/js/directives/ui/vulnerability-priority-view.js @@ -0,0 +1,18 @@ +/** + * An element which displays a priority triangle for vulnerabilities. + */ +angular.module('quay').directive('vulnerabilityPriorityView', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/vulnerability-priority-view.html', + replace: false, + transclude: true, + restrict: 'C', + scope: { + 'priority': '=priority' + }, + controller: function($scope, $element) { + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/pages/image-view.js b/static/js/pages/image-view.js index d848440f2..22da844a7 100644 --- a/static/js/pages/image-view.js +++ b/static/js/pages/image-view.js @@ -10,11 +10,16 @@ }) }]); - function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService) { + function ImageViewCtrl($scope, $routeParams, $rootScope, $timeout, ApiService, ImageMetadataService, VulnerabilityService, Features) { var namespace = $routeParams.namespace; var name = $routeParams.name; var imageid = $routeParams.image; + $scope.options = { + 'vulnFilter': '', + 'packageFilter': '' + }; + var loadImage = function() { var params = { 'repository': namespace + '/' + name, @@ -41,7 +46,7 @@ loadRepository(); $scope.downloadPackages = function() { - if ($scope.packagesResource) { return; } + if (!Features.SECURITY_SCANNER || $scope.packagesResource) { return; } var params = { 'repository': namespace + '/' + name, @@ -53,6 +58,26 @@ }); }; + $scope.loadImageVulnerabilities = function() { + if (!Features.SECURITY_SCANNER || $scope.vulnerabilitiesResource) { return; } + + var params = { + 'repository': namespace + '/' + name, + 'imageid': imageid + }; + + $scope.vulnerabilitiesResource = ApiService.getRepoImageVulnerabilitiesAsResource(params).get(function(resp) { + $scope.vulerabilityInfo = resp; + $scope.vulnerabilities = []; + + resp.data.Vulnerabilities.forEach(function(vuln) { + vuln_copy = jQuery.extend({}, vuln); + vuln_copy['index'] = VulnerabilityService.LEVELS[vuln['Priority']]['index']; + $scope.vulnerabilities.push(vuln_copy); + }); + }); + }; + $scope.downloadChanges = function() { if ($scope.changesResource) { return; } diff --git a/static/partials/image-view.html b/static/partials/image-view.html index ee306150f..6e4b40767 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -25,8 +25,14 @@ tab-init="downloadChanges()"> + + + + tab-init="downloadPackages()" + quay-show="Features.SECURITY_SCANNER"> @@ -58,9 +64,57 @@ + +
+
+
+ +

Image Security

+
+
This image has not been indexed yet
+
+ Please try again in a few minutes. +
+
+
+
This image contains no recognized security vulnerabilities
+
+ Quay currently indexes Debian, Red Hat and Ubuntu packages. +
+
+ +
+
Package Name Package Version OS
{{ package.Name }} {{ package.Version }} {{ package.OS }} diff --git a/static/js/directives/ui/repository-events-table.js b/static/js/directives/ui/repository-events-table.js index 05b3e3226..8f7346e1c 100644 --- a/static/js/directives/ui/repository-events-table.js +++ b/static/js/directives/ui/repository-events-table.js @@ -43,6 +43,18 @@ angular.module('quay').directive('repositoryEventsTable', function () { $scope.showNewNotificationCounter++; }; + $scope.findEnumValue = function(values, index) { + var found = null; + Object.keys(values).forEach(function(key) { + if (values[key]['index'] == index) { + found = values[key]; + return + } + }); + + return found + }; + $scope.getEventInfo = function(notification) { return ExternalNotificationData.getEventInfo(notification.event); }; diff --git a/static/js/services/notification-service.js b/static/js/services/notification-service.js index bd4c8b70c..31ff5af2c 100644 --- a/static/js/services/notification-service.js +++ b/static/js/services/notification-service.js @@ -129,7 +129,8 @@ function($rootScope, $interval, UserService, ApiService, StringBuilderService, P 'message': 'A {vulnerability.priority} vulnerability was detected in repository {repository}', 'page': function(metadata) { return '/repository/' + metadata.repository + '?tab=tags'; - } + }, + 'dismissable': true } }; diff --git a/static/js/services/vulnerability-service.js b/static/js/services/vulnerability-service.js index 752861adb..12c1a172f 100644 --- a/static/js/services/vulnerability-service.js +++ b/static/js/services/vulnerability-service.js @@ -3,89 +3,7 @@ */ angular.module('quay').factory('VulnerabilityService', ['Config', function(Config) { var vulnService = {}; - - // NOTE: This objects are used directly in the external-notification-data service, so make sure - // to update that code if the format here is changed. - vulnService.LEVELS = { - 'Unknown': { - 'title': 'Unknown', - 'index': '6', - 'level': 'info', - - 'description': 'Unknown is either a security problem that has not been assigned ' + - 'to a priority yet or a priority that our system did not recognize', - 'banner_required': false - }, - - 'Negligible': { - 'title': 'Negligible', - 'index': '5', - 'level': 'info', - - 'description': 'Negligible is technically a security problem, but is only theoretical ' + - 'in nature, requires a very special situation, has almost no install base, ' + - 'or does no real damage.', - 'banner_required': false - }, - - 'Low': { - 'title': 'Low', - 'index': '4', - 'level': 'warning', - - 'description': 'Low is a security problem, but is hard to exploit due to environment, ' + - 'requires a user-assisted attack, a small install base, or does very ' + - 'little damage.', - 'banner_required': false - }, - - 'Medium': { - 'title': 'Medium', - 'value': 'Medium', - 'index': '3', - 'level': 'warning', - - 'description': 'Medium is a real security problem, and is exploitable for many people. ' + - 'Includes network daemon denial of service attacks, cross-site scripting, ' + - 'and gaining user privileges.', - 'banner_required': false - }, - - 'High': { - 'title': 'High', - 'value': 'High', - 'index': '2', - 'level': 'warning', - - 'description': 'High is a real problem, exploitable for many people in a default installation. ' + - 'Includes serious remote denial of services, local root privilege escalations, ' + - 'or data loss.', - 'banner_required': false - }, - - 'Critical': { - 'title': 'Critical', - 'value': 'Critical', - 'index': '1', - 'level': 'error', - - 'description': 'Critical is a world-burning problem, exploitable for nearly all people in ' + - 'a installation of the package. Includes remote root privilege escalations, ' + - 'or massive data loss.', - 'banner_required': true - }, - - 'Defcon1': { - 'title': 'Defcon 1', - 'value': 'Defcon1', - 'index': '0', - 'level': 'error', - - 'description': 'Defcon1 is a Critical problem which has been manually highlighted ' + - 'by the Quay team. It requires immediate attention.', - 'banner_required': true - } - }; + vulnService.LEVELS = window.__vuln_priority; vulnService.getLevels = function() { return Object.keys(vulnService.LEVELS).map(function(key) { diff --git a/templates/base.html b/templates/base.html index c3f09c8ce..47eaa66ea 100644 --- a/templates/base.html +++ b/templates/base.html @@ -38,6 +38,7 @@ window.__config = {{ config_set|safe }}; window.__oauth = {{ oauth_set|safe }}; window.__auth_scopes = {{ scope_set|safe }}; + window.__vuln_priority = {{ vuln_priority_set|safe }} window.__token = '{{ csrf_token() }}'; diff --git a/util/sec/__init__.py b/util/sec/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/util/sec/secendpoint.py b/util/sec/secendpoint.py deleted file mode 100644 index 9e9e57413..000000000 --- a/util/sec/secendpoint.py +++ /dev/null @@ -1,51 +0,0 @@ -import features -import logging -import requests -import json - -from urlparse import urljoin - -logger = logging.getLogger(__name__) - -class SecEndpoint(object): - """ Helper class for talking to the Sec API. """ - def __init__(self, app, config_provider): - self.app = app - self.config_provider = config_provider - - if not features.SECURITY_SCANNER: - return - - self.security_config = app.config['SECURITY_SCANNER'] - - self.certificate = self._getfilepath('CA_CERTIFICATE_FILENAME') or False - self.public_key = self._getfilepath('PUBLIC_KEY_FILENAME') - self.private_key = self._getfilepath('PRIVATE_KEY_FILENAME') - - if self.public_key and self.private_key: - self.keys = (self.public_key, self.private_key) - else: - self.keys = None - - def _getfilepath(self, config_key): - security_config = self.security_config - - if config_key in security_config: - with self.config_provider.get_volume_file(security_config[config_key]) as f: - return f.name - - return None - - def call_api(self, relative_url, *args, **kwargs): - """ Issues an HTTP call to the sec API at the given relative URL. """ - security_config = self.security_config - api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' - url = urljoin(api_url, relative_url % args) - - client = self.app.config['HTTPCLIENT'] - timeout = security_config.get('API_CALL_TIMEOUT', 1) - logger.debug('Looking up sec information: %s', url) - - return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) - diff --git a/util/secscan/api.py b/util/secscan/api.py index e03a19369..b0138eeef 100644 --- a/util/secscan/api.py +++ b/util/secscan/api.py @@ -2,12 +2,103 @@ import features import logging import requests -from app import app -from database import CloseForLongOperation +from data.database import CloseForLongOperation from urlparse import urljoin logger = logging.getLogger(__name__) +# NOTE: This objects are used directly in the external-notification-data and vulnerability-service +# on the frontend, so be careful with changing their existing keys. +PRIORITY_LEVELS = { + 'Unknown': { + 'title': 'Unknown', + 'index': '6', + 'level': 'info', + + 'description': 'Unknown is either a security problem that has not been assigned ' + + 'to a priority yet or a priority that our system did not recognize', + 'banner_required': False + }, + + 'Negligible': { + 'title': 'Negligible', + 'index': '5', + 'level': 'info', + + 'description': 'Negligible is technically a security problem, but is only theoretical ' + + 'in nature, requires a very special situation, has almost no install base, ' + + 'or does no real damage.', + 'banner_required': False + }, + + 'Low': { + 'title': 'Low', + 'index': '4', + 'level': 'warning', + + 'description': 'Low is a security problem, but is hard to exploit due to environment, ' + + 'requires a user-assisted attack, a small install base, or does very ' + + 'little damage.', + 'banner_required': False + }, + + 'Medium': { + 'title': 'Medium', + 'value': 'Medium', + 'index': '3', + 'level': 'warning', + + 'description': 'Medium is a real security problem, and is exploitable for many people. ' + + 'Includes network daemon denial of service attacks, cross-site scripting, ' + + 'and gaining user privileges.', + 'banner_required': False + }, + + 'High': { + 'title': 'High', + 'value': 'High', + 'index': '2', + 'level': 'warning', + + 'description': 'High is a real problem, exploitable for many people in a default installation. ' + + 'Includes serious remote denial of services, local root privilege escalations, ' + + 'or data loss.', + 'banner_required': False + }, + + 'Critical': { + 'title': 'Critical', + 'value': 'Critical', + 'index': '1', + 'level': 'error', + + 'description': 'Critical is a world-burning problem, exploitable for nearly all people in ' + + 'a installation of the package. Includes remote root privilege escalations, ' + + 'or massive data loss.', + 'banner_required': True + }, + + 'Defcon1': { + 'title': 'Defcon 1', + 'value': 'Defcon1', + 'index': '0', + 'level': 'error', + + 'description': 'Defcon1 is a Critical problem which has been manually highlighted ' + + 'by the Quay team. It requires immediate attention.', + 'banner_required': True + } +} + + +def get_priority_for_index(index): + for priority in PRIORITY_LEVELS: + if PRIORITY_LEVELS[priority]['index'] == index: + return priority + + return 'Unknown' + + class SecurityScannerAPI(object): """ Helper class for talking to the Security Scan service (Clair). """ def __init__(self, app, config_provider): @@ -76,7 +167,7 @@ class SecurityScannerAPI(object): timeout = security_config.get('API_TIMEOUT_SECONDS', 1) logger.debug('Looking up sec information: %s', url) - with CloseForLongOperation(app.config): + with CloseForLongOperation(self.app.config): if body is not None: return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self.keys, verify=self.certificate) diff --git a/workers/notificationworker.py b/workers/notificationworker.py index 3d3e2f80f..9c747303f 100644 --- a/workers/notificationworker.py +++ b/workers/notificationworker.py @@ -34,7 +34,8 @@ class NotificationWorker(QueueWorker): logger.exception('Cannot find notification event: %s', ex.message) raise JobException('Cannot find notification event: %s' % ex.message) - method_handler.perform(notification, event_handler, job_details) + if event_handler.should_perform(job_details['event_data'], notification): + method_handler.perform(notification, event_handler, job_details) if __name__ == "__main__": From 76ce63895f35f9ba2ae33f2e3aaa84f2c436a471 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Wed, 11 Nov 2015 15:52:30 -0500 Subject: [PATCH 25/36] New Quay Sec UI and fix some small bugs Fixes #855 --- endpoints/api/secscan.py | 31 +++---- endpoints/secscan.py | 2 +- .../directives/repo-view/repo-panel-tags.css | 42 ++++----- static/css/directives/ui/filter-box.css | 31 +++++++ .../ui/vulnerability-priority-view.css | 19 ++++ static/css/pages/image-view.css | 20 +++++ .../directives/repo-view/repo-panel-tags.html | 86 ++++++++++--------- .../vulnerability-priority-view.html | 5 ++ .../directives/repo-view/repo-panel-tags.js | 61 ++++++++----- .../ui/vulnerability-priority-view.js | 18 ++++ static/js/pages/image-view.js | 29 ++++++- static/partials/image-view.html | 76 ++++++++++++++-- workers/securityworker.py | 2 +- 13 files changed, 307 insertions(+), 115 deletions(-) create mode 100644 static/css/directives/ui/vulnerability-priority-view.css create mode 100644 static/directives/vulnerability-priority-view.html create mode 100644 static/js/directives/ui/vulnerability-priority-view.js diff --git a/endpoints/api/secscan.py b/endpoints/api/secscan.py index 56fcea95d..9a1fee133 100644 --- a/endpoints/api/secscan.py +++ b/endpoints/api/secscan.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: - response = secscan_api.call(relative_url, body=None, *args, **kwargs) + response = secscan_api.call(relative_url, None, *args, **kwargs) except requests.exceptions.Timeout: raise DownstreamIssue(payload=dict(message='API call timed out')) except requests.exceptions.ConnectionError: @@ -40,32 +40,32 @@ def _call_security_api(relative_url, *args, **kwargs): @show_if(features.SECURITY_SCANNER) -@resource('/v1/repository//tag//vulnerabilities') +@resource('/v1/repository//image//vulnerabilities') @path_param('repository', 'The full path of the repository. e.g. namespace/name') -@path_param('tag', 'The name of the tag') -class RepositoryTagVulnerabilities(RepositoryParamResource): - """ Operations for managing the vulnerabilities in a repository tag. """ +@path_param('imageid', 'The image ID') +class RepositoryImageVulnerabilities(RepositoryParamResource): + """ Operations for managing the vulnerabilities in a repository image. """ @require_repo_read - @nickname('getRepoTagVulnerabilities') + @nickname('getRepoImageVulnerabilities') @parse_args @query_param('minimumPriority', 'Minimum vulnerability priority', type=str, default='Low') - def get(self, args, namespace, repository, tag): + def get(self, args, namespace, repository, imageid): """ Fetches the vulnerabilities (if any) for a repository tag. """ - try: - tag_image = model.tag.get_tag_image(namespace, repository, tag) - except model.DataModelException: + repo_image = model.image.get_repo_image(namespace, repository, imageid) + if repo_image is None: raise NotFound() - if not tag_image.security_indexed: - logger.debug('Image %s for tag %s under repository %s/%s not security indexed', - tag_image.docker_image_id, tag, namespace, repository) + if not repo_image.security_indexed: + logger.debug('Image %s under repository %s/%s not security indexed', + repo_image.docker_image_id, namespace, repository) return { 'security_indexed': False } - data = _call_security_api('layers/%s/vulnerabilities', tag_image.docker_image_id, + layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid) + data = _call_security_api('layers/%s/vulnerabilities', layer_id, minimumPriority=args.minimumPriority) return { @@ -94,7 +94,8 @@ class RepositoryImagePackages(RepositoryParamResource): 'security_indexed': False } - data = _call_security_api('layers/%s/packages/diff', repo_image.docker_image_id) + layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid) + data = _call_security_api('layers/%s/packages', layer_id) return { 'security_indexed': True, diff --git a/endpoints/secscan.py b/endpoints/secscan.py index 7576318e8..4326ea621 100644 --- a/endpoints/secscan.py +++ b/endpoints/secscan.py @@ -21,5 +21,5 @@ def secscan_notification(): if not layer_ids: return make_response('Okay') - secscan_notification_queue.put(data['Name'], json.dumps(data)) + secscan_notification_queue.put(['notification', data['Name']], json.dumps(data)) return make_response('Okay') diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 6bc6975e1..f2818187b 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -85,46 +85,34 @@ margin-right: 2px; } -.repo-panel-tags-element .fa-flag { +.repo-panel-tags-element .security-scan-col span { cursor: pointer; } -.repo-panel-tags-element .vuln-name { - +.repo-panel-tags-element .security-scan-col i.fa { + margin-right: 4px; } -.repo-panel-tags-element .vuln-description { - color: #aaa; - font-size: 10px; - white-space: normal; +.repo-panel-tags-element .security-scan-col .scanning { + color: #9B9B9B; + font-size: 12px; } -.repo-panel-tags-element .fa-flag.None { - color: #00CA00; +.repo-panel-tags-element .security-scan-col .no-vulns a { + color: #2FC98E; } -.repo-panel-tags-element .fa-flag.Medium { - color: orange; +.repo-panel-tags-element .security-scan-col .vuln-link, +.repo-panel-tags-element .security-scan-col .vuln-link span { + text-decoration: none !important } -.repo-panel-tags-element .fa-flag.High { - color: red; +.repo-panel-tags-element .security-scan-col .has-vulns.Critical .highest-vuln, +.repo-panel-tags-element .security-scan-col .has-vulns.Defcon1 .highest-vuln { } -.repo-panel-tags-element .vuln-dropdown ul { - min-width: 400px; -} - -@keyframes flickerAnimation { /* flame pulses */ - 0% { opacity:1; } - 50% { opacity:0; } - 100% { opacity:1; } -} - -.repo-panel-tags-element .fa-flag.Critical { - color: red; - opacity:1; - animation: flickerAnimation 1s infinite; +.repo-panel-tags-element .other-vulns { + color: black; } @media (max-width: 767px) { diff --git a/static/css/directives/ui/filter-box.css b/static/css/directives/ui/filter-box.css index 82e43c9c6..836a72b11 100644 --- a/static/css/directives/ui/filter-box.css +++ b/static/css/directives/ui/filter-box.css @@ -15,4 +15,35 @@ margin-right: 10px; margin-bottom: 10px; color: #ccc; +} + +.filter-box.floating { + float: right; + min-width: 300px; + margin-top: 0px; + position: relative; +} + +.filter-box.floating .filter-message { + position: absolute; + left: -200px; + top: 7px; +} + + +@media (max-width: 767px) { + .filter-box.floating { + float: none; + width: 100%; + display: block; + margin-top: 10px; + } + + .filter-box.floating .form-control { + max-width: 100%; + } + + .filter-box.floating .filter-message { + display: none; + } } \ No newline at end of file diff --git a/static/css/directives/ui/vulnerability-priority-view.css b/static/css/directives/ui/vulnerability-priority-view.css new file mode 100644 index 000000000..c3487d6cb --- /dev/null +++ b/static/css/directives/ui/vulnerability-priority-view.css @@ -0,0 +1,19 @@ +.vulnerability-priority-view-element i.fa { + margin-right: 4px; +} + +.vulnerability-priority-view-element.Unknown, +.vulnerability-priority-view-element.Low, +.vulnerability-priority-view-element.Negligable { + color: #9B9B9B; +} + +.vulnerability-priority-view-element.Medium { + color: #FCA657; +} + +.vulnerability-priority-view-element.High, +.vulnerability-priority-view-element.Critical, +.vulnerability-priority-view-element.Defcon1 { + color: #D64456; +} diff --git a/static/css/pages/image-view.css b/static/css/pages/image-view.css index f91ceacc9..6b5cfa555 100644 --- a/static/css/pages/image-view.css +++ b/static/css/pages/image-view.css @@ -23,3 +23,23 @@ .image-view .co-tab-content h3 { margin-bottom: 20px; } + +.image-view .fa-bug { + margin-right: 4px; +} + +.image-view .co-filter-box { + float: right; + min-width: 300px; + margin-bottom: 10px; +} + +.image-view .co-filter-box .current-filtered { + display: inline-block; + margin-right: 10px; + color: #999; +} + +.image-view .co-filter-box input { + display: inline-block; +} \ No newline at end of file diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 7d369f3f9..0f1125d8e 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -81,17 +81,17 @@ style="min-width: 120px;"> Last Modified - - - - + - - - - - + + Queued for scan + + + + + + Passed + + + + + + + + + {{ getTagVulnerabilities(tag).highestVulnerability.Count }} + + + + + + {{ getTagVulnerabilities(tag).vulnerabilities.length - getTagVulnerabilities(tag).highestVulnerability.Count }} others + + + + More Info + +
+ + + + + + + + + + +
VulnerabilityPriorityDescription
{{ vulnerability.ID }} + + {{ vulnerability.Description }}
+ +
+
No matching vulnerabilities found
+
+ Please adjust your filter above. +
+
+
+ + + -
+
+
+

Image Packages

This image has not been indexed yet
@@ -75,19 +129,27 @@
- +
- - - + + + - +
Package NamePackage VersionOSPackage NamePackage VersionOS
{{ package.Name }} {{ package.Version }} {{ package.OS }}
+ +
+
No matching packages found
+
+ Please adjust your filter above. +
+
diff --git a/workers/securityworker.py b/workers/securityworker.py index 26d360754..fd0702d81 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -256,7 +256,7 @@ class SecurityWorker(Worker): # callback code, etc. try: logger.debug('Loading vulnerabilities for layer %s', img['image_id']) - response = secscan_api.call('layers/%s/vulnerabilities', request['ID']) + response = secscan_api.call('layers/%s/vulnerabilities', None, request['ID']) except requests.exceptions.Timeout: logger.debug('Timeout when calling Sec') continue From e86a34286885414f064b9395da21f8503730e7d2 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Thu, 12 Nov 2015 15:46:31 -0500 Subject: [PATCH 26/36] create class for security config validation --- util/secscan/api.py | 72 ++++++++++++++++++++++++++++++++------------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/util/secscan/api.py b/util/secscan/api.py index b0138eeef..331e45294 100644 --- a/util/secscan/api.py +++ b/util/secscan/api.py @@ -98,6 +98,54 @@ def get_priority_for_index(index): return 'Unknown' +class SecurityConfigValidator(object): + def __init__(self, app, config_provider): + self._config_provider = config_provider + + if not features.SECURITY_SCANNER: + return + + self._security_config = app.config['SECURITY_SCANNER'] + if self._security_config is None: + return + + self._certificate = self._get_filepath('CA_CERTIFICATE_FILENAME') or False + self._public_key = self._get_filepath('PUBLIC_KEY_FILENAME') + self._private_key = self._get_filepath('PRIVATE_KEY_FILENAME') + + if self._public_key and self._private_key: + self._keys = (self._public_key, self._private_key) + else: + self._keys = None + + def _get_filepath(self, key): + config = self._security_config + + if key in config: + with self._config_provider.get_volume_file(config[key]) as f: + return f.name + + return None + + def cert(self): + return self._certificate + + def keypair(self): + return self._keys + + def valid(self): + config = self._security_config + + if (not features.SECURITY_SCANNER + or not config + or not 'ENDPOINT' in config + or not 'ENGINE_VERSION_TARGET' in config + or not 'DISTRIBUTED_STORAGE_PREFERENCE' in config + or (self._certificate is False and self._keys is None)): + return False + + return True + class SecurityScannerAPI(object): """ Helper class for talking to the Security Scan service (Clair). """ @@ -105,28 +153,12 @@ class SecurityScannerAPI(object): self.app = app self.config_provider = config_provider - if not features.SECURITY_SCANNER: + config_validator = SecurityConfigValidator(app, config_provider) + if not config_validator.valid(): return - self.security_config = app.config['SECURITY_SCANNER'] - - self.certificate = self._getfilepath('CA_CERTIFICATE_FILENAME') or False - self.public_key = self._getfilepath('PUBLIC_KEY_FILENAME') - self.private_key = self._getfilepath('PRIVATE_KEY_FILENAME') - - if self.public_key and self.private_key: - self.keys = (self.public_key, self.private_key) - else: - self.keys = None - - def _getfilepath(self, config_key): - security_config = self.security_config - - if config_key in security_config: - with self.config_provider.get_volume_file(security_config[config_key]) as f: - return f.name - - return None + self.certificate = config_validator.cert() + self.keys = config_validator.keypair() def check_layer_vulnerable(self, layer_id, cve_id): """ Checks with Clair whether the given layer is vulnerable to the given CVE. """ From f6a34c5d0627f4478dd0c25f2684166a485f1a90 Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Wed, 11 Nov 2015 15:41:46 -0500 Subject: [PATCH 27/36] refactor securityworker Fixes #772. --- workers/securityworker.py | 291 ++++++++++++++++++-------------------- 1 file changed, 137 insertions(+), 154 deletions(-) diff --git a/workers/securityworker.py b/workers/securityworker.py index 26d360754..1f4328741 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -6,17 +6,16 @@ import features import time import os import random -import json from endpoints.notificationhelper import spawn_notification from collections import defaultdict -from sys import exc_info from peewee import JOIN_LEFT_OUTER -from app import app, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_api +from app import app, config_provider, storage, OVERRIDE_CONFIG_DIRECTORY, secscan_api from workers.worker import Worker from data.database import (Image, ImageStorage, ImageStorageLocation, ImageStoragePlacement, db_random_func, UseThenDisconnect, RepositoryTag, Repository, ExternalNotificationEvent, RepositoryNotification) +from util.secscan.api import SecurityConfigValidator logger = logging.getLogger(__name__) @@ -25,12 +24,12 @@ INDEXING_INTERVAL = 10 API_METHOD_INSERT = '/v1/layers' API_METHOD_VERSION = '/v1/versions/engine' -def _get_image_to_export(version): +def _get_images_to_export_list(version): Parent = Image.alias() ParentImageStorage = ImageStorage.alias() rimages = [] - # Without parent + # Collect the images without parents candidates = (Image .select(Image.id, Image.docker_image_id, ImageStorage.uuid) .join(ImageStorage) @@ -54,7 +53,7 @@ def _get_image_to_export(version): 'parent_docker_image_id': None, 'parent_storage_uuid': None}) - # With analyzed parent + # Collect the images with analyzed parents. candidates = (Image .select(Image.id, Image.docker_image_id, @@ -90,9 +89,8 @@ def _get_image_to_export(version): 'parent_docker_image_id': image[3], 'parent_storage_uuid': image[4]}) - # Re-shuffle, otherwise the images without parents will always be on the top + # Shuffle the images, otherwise the images without parents will always be on the top random.shuffle(rimages) - return rimages def _get_storage_locations(uuid): @@ -102,12 +100,8 @@ def _get_storage_locations(uuid): .switch(ImageStoragePlacement) .join(ImageStorage, JOIN_LEFT_OUTER) .where(ImageStorage.uuid == uuid)) - return query.get() - locations = list() - for location in query: - locations.append(location.location.name) - return locations + return [location.location.name for location in query] def _update_image(image, indexed, version): query = (Image @@ -116,174 +110,163 @@ def _update_image(image, indexed, version): .where(Image.docker_image_id == image['docker_image_id'], ImageStorage.uuid == image['storage_uuid'])) - updated_images = list() - for row in query: - updated_images.append(row.id) - (Image - .update(security_indexed=indexed, security_indexed_engine=version) - .where(Image.id << updated_images) - .execute()) + .update(security_indexed=indexed, security_indexed_engine=version) + .where(Image.id << [row.id for row in query]) + .execute()) + class SecurityWorker(Worker): def __init__(self): super(SecurityWorker, self).__init__() - if self._load_configuration(): + validator = SecurityConfigValidator(app.config, config_provider) + if validator.valid(): + secscan_config = app.config.get('SECURITY_SCANNER') + self._api = secscan_config['ENDPOINT'] + self._target_version = secscan_config['ENGINE_VERSION_TARGET'] + self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE'] + self._cert = validator.cert() + self._keys = validator.keypair() + self.add_operation(self._index_images, INDEXING_INTERVAL) - def _load_configuration(self): - # Load configuration - config = app.config.get('SECURITY_SCANNER') + def _get_image_url(self, image): + """ Gets the download URL for an image and if the storage doesn't exist, + marks the image as unindexed. """ + path = storage.image_layer_path(image['storage_uuid']) + locations = self._default_storage_locations - if (not config - or not 'ENDPOINT' in config or not 'ENGINE_VERSION_TARGET' in config - or not 'DISTRIBUTED_STORAGE_PREFERENCE' in app.config): - logger.exception('No configuration found for the security worker') - return False - self._api = config['ENDPOINT'] - self._target_version = config['ENGINE_VERSION_TARGET'] - self._default_storage_locations = app.config['DISTRIBUTED_STORAGE_PREFERENCE'] + if not storage.exists(locations, path): + locations = _get_storage_locations(image['storage_uuid']) - self._ca = False - self._cert = None - if 'CA_CERTIFICATE_FILENAME' in config: - self._ca = os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['CA_CERTIFICATE_FILENAME']) - if not os.path.isfile(self._ca): - logger.exception('Could not find configured CA file') - return False - if 'PRIVATE_KEY_FILENAME' in config and 'PUBLIC_KEY_FILENAME' in config: - self._cert = ( - os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PUBLIC_KEY_FILENAME']), - os.path.join(OVERRIDE_CONFIG_DIRECTORY, config['PRIVATE_KEY_FILENAME']), - ) - if not os.path.isfile(self._cert[0]) or not os.path.isfile(self._cert[1]): - logger.exception('Could not find configured key pair files') - return False + if not storage.exists(locations, path): + logger.warning('Could not find a valid location to download layer %s', + image['docker_image_id']+'.'+image['storage_uuid']) + try: + _update_image(image, False, self._target_version) + except: + logger.exception('Failed to update unindexed image') + return None - return True + uri = storage.get_direct_download_url(locations, path) + # Local storage hack + if uri is None: + uri = path + + return uri + + def _new_request(self, image): + url = self._get_image_url(image) + if url is None: + return None + + request = { + 'ID': image['docker_image_id']+'.'+image['storage_uuid'], + 'Path': url, + } + + if image['parent_docker_image_id'] is not None and image['parent_storage_uuid'] is not None: + request['ParentID'] = image['parent_docker_image_id']+'.'+image['parent_storage_uuid'] + + return request + + def _analyze_image(self, image): + request = self._new_request(image) + if request is None: + return None + + try: + logger.info('Analyzing %s', request['ID']) + # Using invalid certificates doesn't return proper errors because of + # https://github.com/shazow/urllib3/issues/556 + httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, + cert=self._keys, verify=self._cert) + jsonResponse = httpResponse.json() + except: + logger.exception('An exception occurred when analyzing layer ID %s', request['ID']) + return None + + # Handle any errors from the security scanner. + if httpResponse.status_code != 201: + if 'Message' in jsonResponse: + if 'OS and/or package manager are not supported' in jsonResponse['Message']: + # The current engine could not index this layer + logger.warning('A warning event occurred when analyzing layer ID %s : %s', + request['ID'], jsonResponse['Message']) + + # Hopefully, there is no version lower than the target one running + try: + _update_image(image, False, self._target_version) + except: + logger.exception('Failed to update image to be unindexed') + else: + logger.warning('Failed to handle JSON message "%s" when analyzing layer ID %s', + jsonResponse['Message'], request['ID']) + return None + else: + logger.warning('No message found in JSON response when analyzing layer ID %s', request['ID']) + return None + + api_version = jsonResponse['Version'] + if api_version < self._target_version: + logger.warning('An engine runs on version %d but the target version is %d') + + try: + _update_image(image, True, api_version) + logger.debug('Layer %s analyzed successfully', request['ID']) + except: + logger.exception('Failed to update image to be indexed') + + logger.debug('Loading vulnerabilities for layer %s', image['image_id']) + try: + response = secscan_api.call('layers/%s/vulnerabilities', None, request['ID']) + logger.debug('Got response %s for vulnerabilities for layer %s', + response.status_code, image['image_id']) + if response.status_code == 404: + return None + except: + logger.exception('Failed to get vulnerability response for %s', image['image_id']) + return None + + return response.json() def _index_images(self): - logger.debug('Starting indexing') + logger.debug('Started indexing') with UseThenDisconnect(app.config): while True: - logger.debug('Looking up images to index') - - # Get images to analyze + images = [] try: - images = _get_image_to_export(self._target_version) - if not images: - logger.debug('No more image to analyze') - return - + logger.debug('Looking up images to index') + images = _get_images_to_export_list(self._target_version) except Image.DoesNotExist: - logger.debug('No more image to analyze') + pass + + if not images: + logger.debug('No more images left to analyze') return + logger.debug('Found %d images to index' % len(images)) - logger.debug('Found images to index: %s', images) - for img in images: - # Get layer storage URL - path = storage.image_layer_path(img['storage_uuid']) - locations = self._default_storage_locations - - if not storage.exists(locations, path): - locations = _get_storage_locations(img['storage_uuid']) - - if not storage.exists(locations, path): - logger.warning('Could not find a valid location to download layer %s', - img['docker_image_id']+'.'+img['storage_uuid']) - _update_image(img, False, self._target_version) - continue - - uri = storage.get_direct_download_url(locations, path) - if uri == None: - # Local storage hack - uri = path - - # Forge request - request = { - 'ID': img['docker_image_id']+'.'+img['storage_uuid'], - 'Path': uri - } - if img['parent_docker_image_id'] is not None and img['parent_storage_uuid'] is not None: - request['ParentID'] = img['parent_docker_image_id']+'.'+img['parent_storage_uuid'] - - # Post request - try: - logger.info('Analyzing %s', request['ID']) - # Using invalid certificates doesn't return proper errors because of - # https://github.com/shazow/urllib3/issues/556 - httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, - cert=self._cert, verify=self._ca) - except: - logger.exception('An exception occurred when analyzing layer ID %s : %s', - request['ID'], exc_info()[0]) - return - try: - jsonResponse = httpResponse.json() - except: - logger.exception('An exception occurred when analyzing layer ID %s : the response is \ - not valid JSON (%s)', request['ID'], httpResponse.text) - return - - if httpResponse.status_code != 201: - if 'Message' in jsonResponse: - if 'OS and/or package manager are not supported' in jsonResponse['Message']: - # The current engine could not index this layer - logger.warning('A warning event occurred when analyzing layer ID %s : %s', - request['ID'], jsonResponse['Message']) - # Hopefully, there is no version lower than the target one running - _update_image(img, False, self._target_version) - else: - logger.exception('An exception occurred when analyzing layer ID %s : %d %s', - request['ID'], httpResponse.status_code, jsonResponse['Message']) - return - else: - logger.exception('An exception occurred when analyzing layer ID %s : %d', - request['ID'], httpResponse.status_code) - return - - # The layer has been successfully indexed - api_version = jsonResponse['Version'] - if api_version < self._target_version: - logger.warning('An engine runs on version %d but the target version is %d') - - logger.debug('Layer %s analyzed successfully', request['ID']) - _update_image(img, True, api_version) - - - # TODO(jschorr): Put this in a proper place, properly comment, unify with the - # callback code, etc. - try: - logger.debug('Loading vulnerabilities for layer %s', img['image_id']) - response = secscan_api.call('layers/%s/vulnerabilities', request['ID']) - except requests.exceptions.Timeout: - logger.debug('Timeout when calling Sec') + for image in images: + sec_data = self._analyze_image(image) + if sec_data is None: continue - except requests.exceptions.ConnectionError: - logger.debug('Connection error when calling Sec') - continue - - logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, img['image_id']) - if response.status_code == 404: - continue - - sec_data = json.loads(response.text) - logger.debug('Got response vulnerabilities for layer %s: %s', img['image_id'], sec_data) + logger.debug('Got response vulnerabilities for layer %s: %s', image['image_id'], sec_data) if not sec_data['Vulnerabilities']: continue + # Dispatch events for any detected vulnerabilities event = ExternalNotificationEvent.get(name='vulnerability_found') matching = (RepositoryTag - .select(RepositoryTag, Repository) - .distinct() - .join(Repository) - .join(RepositoryNotification) - .where(RepositoryNotification.event == event, - RepositoryTag.image == img['image_id'])) + .select(RepositoryTag, Repository) + .distinct() + .join(Repository) + .join(RepositoryNotification) + .where(RepositoryNotification.event == event, + RepositoryTag.image == image['image_id'])) - repository_map = defaultdict(list) + repository_map = defaultdict() for tag in matching: repository_map[tag.repository_id].append(tag) From 3b3f101ea668fd6aa5cfb18515fe91e611668c2f Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 15:42:45 -0500 Subject: [PATCH 28/36] Vulnerability UI part 2 Fixes #860 Fixes #855 --- endpoints/api/secscan.py | 22 +++- .../directives/repo-view/repo-panel-info.css | 50 +++++++- .../directives/repo-view/repo-panel-tags.css | 4 +- static/css/directives/ui/filter-box.css | 2 +- .../ui/repository-events-summary.css | 21 ++++ .../ui/vulnerability-priority-view.css | 2 +- static/css/pages/image-view.css | 27 ++++- .../directives/repo-view/repo-panel-info.html | 20 +++- .../directives/repo-view/repo-panel-tags.html | 19 +++- .../directives/repository-events-summary.html | 22 ++++ .../directives/repository-events-table.html | 1 + static/img/lock.svg | 17 +++ static/img/scan.svg | 14 +++ .../directives/repo-view/repo-panel-info.js | 3 +- .../directives/repo-view/repo-panel-tags.js | 4 +- .../ui/create-external-notification-dialog.js | 17 ++- .../ui/repository-events-summary.js | 77 +++++++++++++ .../directives/ui/repository-events-table.js | 29 ++++- static/js/pages/image-view.js | 19 +++- static/js/pages/repo-view.js | 5 + .../js/services/external-notification-data.js | 4 +- static/partials/image-view.html | 107 +++++++++++------- static/partials/repo-view.html | 6 +- 23 files changed, 419 insertions(+), 73 deletions(-) create mode 100644 static/css/directives/ui/repository-events-summary.css create mode 100644 static/directives/repository-events-summary.html create mode 100644 static/img/lock.svg create mode 100644 static/img/scan.svg create mode 100644 static/js/directives/ui/repository-events-summary.js diff --git a/endpoints/api/secscan.py b/endpoints/api/secscan.py index 9a1fee133..c05e40fef 100644 --- a/endpoints/api/secscan.py +++ b/endpoints/api/secscan.py @@ -15,6 +15,13 @@ from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_pa logger = logging.getLogger(__name__) +class SCAN_STATUS(object): + """ Security scan status enum """ + SCANNED = 'scanned' + FAILED = 'failed' + QUEUED = 'queued' + + def _call_security_api(relative_url, *args, **kwargs): """ Issues an HTTP call to the sec API at the given relative URL. """ try: @@ -39,6 +46,13 @@ def _call_security_api(relative_url, *args, **kwargs): return response_data +def _get_status(repo_image): + if repo_image.security_indexed_engine: + return SCAN_STATUS.SCANNED if repo_image.security_indexed else SCAN_STATUS.FAILED + + return SCAN_STATUS.QUEUED + + @show_if(features.SECURITY_SCANNER) @resource('/v1/repository//image//vulnerabilities') @path_param('repository', 'The full path of the repository. e.g. namespace/name') @@ -61,7 +75,7 @@ class RepositoryImageVulnerabilities(RepositoryParamResource): logger.debug('Image %s under repository %s/%s not security indexed', repo_image.docker_image_id, namespace, repository) return { - 'security_indexed': False + 'status': _get_status(repo_image), } layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid) @@ -69,7 +83,7 @@ class RepositoryImageVulnerabilities(RepositoryParamResource): minimumPriority=args.minimumPriority) return { - 'security_indexed': True, + 'status': _get_status(repo_image), 'data': data, } @@ -91,14 +105,14 @@ class RepositoryImagePackages(RepositoryParamResource): if not repo_image.security_indexed: return { - 'security_indexed': False + 'status': _get_status(repo_image), } layer_id = '%s.%s' % (repo_image.docker_image_id, repo_image.storage.uuid) data = _call_security_api('layers/%s/packages', layer_id) return { - 'security_indexed': True, + 'status': _get_status(repo_image), 'data': data, } diff --git a/static/css/directives/repo-view/repo-panel-info.css b/static/css/directives/repo-view/repo-panel-info.css index 1b39b3a6b..032fd7d4b 100644 --- a/static/css/directives/repo-view/repo-panel-info.css +++ b/static/css/directives/repo-view/repo-panel-info.css @@ -3,10 +3,56 @@ float: right; } +.repo-panel-info-element .right-sec-controls { + border: 1px solid #ddd; + padding: 20px; + border-radius: 4px; + max-width: 400px; +} + +.repo-panel-info-element .right-sec-controls { + color: #333; + font-weight: 300; + padding-left: 70px; + position: relative; +} + +.repo-panel-info-element .right-sec-controls .sec-logo { + position: absolute; + top: 17px; + left: 15px; +} + +.repo-panel-info-element .right-sec-controls .sec-logo .lock { + position: absolute; + top: 5px; + right: 10px; +} + +.repo-panel-info-element .right-sec-controls b { + color: #333; + font-weight: normal; + margin-bottom: 20px; + display: block; +} + +.repo-panel-info-element .right-sec-controls .configure-alerts { + margin-top: 20px; + font-weight: normal; +} + +.repo-panel-info-element .right-sec-controls .configure-alerts .fa { + margin-right: 6px; +} + +.repo-panel-info-element .right-sec-controls .repository-events-summary { + margin-top: 20px; +} + .repo-panel-info-element .right-controls .copy-box { width: 400px; - display: inline-block; - margin-left: 10px; + margin-top: 10px; + margin-bottom: 20px; } .repo-panel-info-element .stat-col { diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index f2818187b..c0a1073c4 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -93,7 +93,9 @@ margin-right: 4px; } -.repo-panel-tags-element .security-scan-col .scanning { +.repo-panel-tags-element .security-scan-col .scanning, +.repo-panel-tags-element .security-scan-col .failed-scan, +.repo-panel-tags-element .security-scan-col .vuln-load-error { color: #9B9B9B; font-size: 12px; } diff --git a/static/css/directives/ui/filter-box.css b/static/css/directives/ui/filter-box.css index 836a72b11..604930a0d 100644 --- a/static/css/directives/ui/filter-box.css +++ b/static/css/directives/ui/filter-box.css @@ -20,7 +20,7 @@ .filter-box.floating { float: right; min-width: 300px; - margin-top: 0px; + margin-top: 15px; position: relative; } diff --git a/static/css/directives/ui/repository-events-summary.css b/static/css/directives/ui/repository-events-summary.css new file mode 100644 index 000000000..1ff4d60b2 --- /dev/null +++ b/static/css/directives/ui/repository-events-summary.css @@ -0,0 +1,21 @@ +.repository-events-summary-element .summary-list { + padding: 0px; + margin: 0px; + list-style: none; +} + +.repository-events-summary-element .summary-list li { + margin-bottom: 6px; +} + +.repository-events-summary-element .summary-list li i.fa { + margin-right: 4px; +} + +.repository-events-summary-element .notification-event-fields { + display: inline-block; + padding: 0px; + margin: 0px; + list-style: none; + padding-left: 24px; +} \ No newline at end of file diff --git a/static/css/directives/ui/vulnerability-priority-view.css b/static/css/directives/ui/vulnerability-priority-view.css index c3487d6cb..88dd4d27c 100644 --- a/static/css/directives/ui/vulnerability-priority-view.css +++ b/static/css/directives/ui/vulnerability-priority-view.css @@ -4,7 +4,7 @@ .vulnerability-priority-view-element.Unknown, .vulnerability-priority-view-element.Low, -.vulnerability-priority-view-element.Negligable { +.vulnerability-priority-view-element.Negligible { color: #9B9B9B; } diff --git a/static/css/pages/image-view.css b/static/css/pages/image-view.css index 6b5cfa555..a1cb55bab 100644 --- a/static/css/pages/image-view.css +++ b/static/css/pages/image-view.css @@ -21,7 +21,7 @@ } .image-view .co-tab-content h3 { - margin-bottom: 20px; + margin-bottom: 30px; } .image-view .fa-bug { @@ -42,4 +42,29 @@ .image-view .co-filter-box input { display: inline-block; +} + +.image-view .level-col h4 { + margin-top: 0px; + margin-bottom: 20px; +} + +.image-view .levels { + list-style: none; + padding: 0px; + margin: 0px; +} + +.image-view .levels li { + margin-bottom: 20px; +} + +.image-view .levels li .description { + margin-top: 6px; + font-size: 14px; + color: #999; +} + +.image-view .level-col { + padding: 20px; } \ No newline at end of file diff --git a/static/directives/repo-view/repo-panel-info.html b/static/directives/repo-view/repo-panel-info.html index 5fdab5a4c..7faf2b417 100644 --- a/static/directives/repo-view/repo-panel-info.html +++ b/static/directives/repo-view/repo-panel-info.html @@ -65,7 +65,25 @@

Description

diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 0f1125d8e..6c182bfe3 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -115,15 +115,26 @@ + + Could not load security information + - - + Queued for scan + + + + Failed to scan + + @@ -134,7 +145,7 @@ - +
+
    +
  • + + {{ getMethodInfo(notification).title }} for + +
      +
    • + {{ field.title }} of + + + {{ findEnumValue(field.values, notification.event_config[field.name]).title }} + + +
    • +
    +
  • +
+
+ \ No newline at end of file diff --git a/static/directives/repository-events-table.html b/static/directives/repository-events-table.html index a83a45931..fd40d9789 100644 --- a/static/directives/repository-events-table.html +++ b/static/directives/repository-events-table.html @@ -100,5 +100,6 @@
diff --git a/static/img/lock.svg b/static/img/lock.svg new file mode 100644 index 000000000..748961f52 --- /dev/null +++ b/static/img/lock.svg @@ -0,0 +1,17 @@ + + + + Artboard 1 + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/static/img/scan.svg b/static/img/scan.svg new file mode 100644 index 000000000..91ea19e43 --- /dev/null +++ b/static/img/scan.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + diff --git a/static/js/directives/repo-view/repo-panel-info.js b/static/js/directives/repo-view/repo-panel-info.js index 2902699ec..f11894732 100644 --- a/static/js/directives/repo-view/repo-panel-info.js +++ b/static/js/directives/repo-view/repo-panel-info.js @@ -10,7 +10,8 @@ angular.module('quay').directive('repoPanelInfo', function () { restrict: 'C', scope: { 'repository': '=repository', - 'builds': '=builds' + 'builds': '=builds', + 'isEnabled': '=isEnabled' }, controller: function($scope, $element, ApiService, Config) { $scope.$watch('repository', function(repository) { diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 6f80d193f..1ea285f45 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -157,10 +157,10 @@ angular.module('quay').directive('repoPanelTags', function () { }; ApiService.getRepoImageVulnerabilities(null, params).then(function(resp) { - imageData.security_indexed = resp.security_indexed; imageData.loading = false; + imageData.status = resp['status']; - if (imageData.security_indexed) { + if (imageData.status == 'scanned') { var vulnerabilities = resp.data.Vulnerabilities; imageData.hasVulnerabilities = !!vulnerabilities.length; diff --git a/static/js/directives/ui/create-external-notification-dialog.js b/static/js/directives/ui/create-external-notification-dialog.js index cbf8696e3..874769216 100644 --- a/static/js/directives/ui/create-external-notification-dialog.js +++ b/static/js/directives/ui/create-external-notification-dialog.js @@ -11,7 +11,8 @@ angular.module('quay').directive('createExternalNotificationDialog', function () scope: { 'repository': '=repository', 'counter': '=counter', - 'notificationCreated': '¬ificationCreated' + 'notificationCreated': '¬ificationCreated', + 'defaultData': '=defaultData' }, controller: function($scope, $element, ExternalNotificationData, ApiService, $timeout, StringBuilderService) { $scope.currentEvent = null; @@ -98,6 +99,13 @@ angular.module('quay').directive('createExternalNotificationDialog', function () ApiService.createRepoNotification(data, params).then(function(resp) { $scope.status = ''; $scope.notificationCreated({'notification': resp}); + + // Used by repository-events-summary. + if (!$scope.repository._notificationCounter) { + $scope.repository._notificationCounter = 0; + } + + $scope.repository._notificationCounter++; $('#createNotificationModal').modal('hide'); }); }; @@ -154,6 +162,13 @@ angular.module('quay').directive('createExternalNotificationDialog', function () $scope.currentEvent = null; $scope.currentMethod = null; $scope.unauthorizedEmail = false; + + $timeout(function() { + if ($scope.defaultData && $scope.defaultData['currentEvent']) { + $scope.setEvent($scope.defaultData['currentEvent']); + } + }, 100); + $('#createNotificationModal').modal({}); } }); diff --git a/static/js/directives/ui/repository-events-summary.js b/static/js/directives/ui/repository-events-summary.js new file mode 100644 index 000000000..50d667334 --- /dev/null +++ b/static/js/directives/ui/repository-events-summary.js @@ -0,0 +1,77 @@ +/** + * An element which displays a summary of events on a repository of a particular type. + */ +angular.module('quay').directive('repositoryEventsSummary', function () { + var directiveDefinitionObject = { + priority: 0, + templateUrl: '/static/directives/repository-events-summary.html', + replace: false, + transclude: false, + restrict: 'C', + scope: { + 'repository': '=repository', + 'isEnabled': '=isEnabled', + 'eventFilter': '@eventFilter', + 'hasEvents': '=hasEvents' + }, + controller: function($scope, ApiService, ExternalNotificationData) { + var loadNotifications = function() { + if (!$scope.repository || !$scope.isEnabled || !$scope.eventFilter || $scope.notificationsResource) { + return; + } + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name + }; + + $scope.notificationsResource = ApiService.listRepoNotificationsAsResource(params).get( + function(resp) { + var notifications = []; + resp.notifications.forEach(function(notification) { + if (notification.event == $scope.eventFilter) { + notifications.push(notification); + } + }); + + $scope.notifications = notifications; + $scope.hasEvents = !!notifications.length; + return $scope.notifications; + }); + }; + + $scope.$watch('repository', loadNotifications); + $scope.$watch('isEnabled', loadNotifications); + $scope.$watch('eventFilter', loadNotifications); + + // Watch _notificationCounter, which is set by create-external-notification-dialog. We use this + // to invalidate and reload. + $scope.$watch('repository._notificationCounter', function() { + $scope.notificationsResource = null; + loadNotifications(); + }); + + loadNotifications(); + + $scope.findEnumValue = function(values, index) { + var found = null; + Object.keys(values).forEach(function(key) { + if (values[key]['index'] == index) { + found = values[key]; + return + } + }); + + return found + }; + + $scope.getEventInfo = function(notification) { + return ExternalNotificationData.getEventInfo(notification.event); + }; + + $scope.getMethodInfo = function(notification) { + return ExternalNotificationData.getMethodInfo(notification.method); + }; + } + }; + return directiveDefinitionObject; +}); \ No newline at end of file diff --git a/static/js/directives/ui/repository-events-table.js b/static/js/directives/ui/repository-events-table.js index 8f7346e1c..8aca804b6 100644 --- a/static/js/directives/ui/repository-events-table.js +++ b/static/js/directives/ui/repository-events-table.js @@ -13,11 +13,29 @@ angular.module('quay').directive('repositoryEventsTable', function () { 'repository': '=repository', 'isEnabled': '=isEnabled' }, - controller: function($scope, $element, ApiService, Restangular, UtilService, ExternalNotificationData) { + controller: function($scope, $element, $timeout, ApiService, Restangular, UtilService, ExternalNotificationData, $location) { $scope.showNewNotificationCounter = 0; + $scope.newNotificationData = {}; var loadNotifications = function() { - if (!$scope.repository || $scope.notificationsResource || !$scope.isEnabled) { return; } + if (!$scope.repository || !$scope.isEnabled) { return; } + + var add_event = $location.search()['add_event']; + if (add_event) { + $timeout(function() { + $scope.newNotificationData = { + 'currentEvent': ExternalNotificationData.getEventInfo(add_event) + }; + + $scope.askCreateNotification(); + }, 100); + + $location.search('add_event', null); + } + + if ($scope.notificationsResource) { + return; + } var params = { 'repository': $scope.repository.namespace + '/' + $scope.repository.name @@ -73,6 +91,13 @@ angular.module('quay').directive('repositoryEventsTable', function () { var index = $.inArray(notification, $scope.notifications); if (index < 0) { return; } $scope.notifications.splice(index, 1); + + if (!$scope.repository._notificationCounter) { + $scope.repository._notificationCounter = 0; + } + + $scope.repository._notificationCounter++; + }, ApiService.errorDisplay('Cannot delete notification')); }; diff --git a/static/js/pages/image-view.js b/static/js/pages/image-view.js index 22da844a7..2783c49a6 100644 --- a/static/js/pages/image-view.js +++ b/static/js/pages/image-view.js @@ -55,26 +55,33 @@ $scope.packagesResource = ApiService.getRepoImagePackagesAsResource(params).get(function(packages) { $scope.packages = packages; + return packages; }); }; $scope.loadImageVulnerabilities = function() { if (!Features.SECURITY_SCANNER || $scope.vulnerabilitiesResource) { return; } + $scope.VulnerabilityLevels = VulnerabilityService.getLevels(); + var params = { 'repository': namespace + '/' + name, 'imageid': imageid }; $scope.vulnerabilitiesResource = ApiService.getRepoImageVulnerabilitiesAsResource(params).get(function(resp) { - $scope.vulerabilityInfo = resp; + $scope.vulnerabilityInfo = resp; $scope.vulnerabilities = []; - resp.data.Vulnerabilities.forEach(function(vuln) { - vuln_copy = jQuery.extend({}, vuln); - vuln_copy['index'] = VulnerabilityService.LEVELS[vuln['Priority']]['index']; - $scope.vulnerabilities.push(vuln_copy); - }); + if (resp.data && resp.data.Vulnerabilities) { + resp.data.Vulnerabilities.forEach(function(vuln) { + vuln_copy = jQuery.extend({}, vuln); + vuln_copy['index'] = VulnerabilityService.LEVELS[vuln['Priority']]['index']; + $scope.vulnerabilities.push(vuln_copy); + }); + } + + return resp; }); }; diff --git a/static/js/pages/repo-view.js b/static/js/pages/repo-view.js index c5ae7f093..e7088d399 100644 --- a/static/js/pages/repo-view.js +++ b/static/js/pages/repo-view.js @@ -17,6 +17,7 @@ var imageLoader = ImageLoaderService.getLoader($scope.namespace, $scope.name); // Tab-enabled counters. + $scope.infoShown = 0; $scope.tagsShown = 0; $scope.logsShown = 0; $scope.buildsShown = 0; @@ -119,6 +120,10 @@ $scope.viewScope.selectedTags = $.unique(tagNames.split(',')); }; + $scope.showInfo = function() { + $scope.infoShown++; + }; + $scope.showBuilds = function() { $scope.buildsShown++; }; diff --git a/static/js/services/external-notification-data.js b/static/js/services/external-notification-data.js index 14de1d24e..b9356f671 100644 --- a/static/js/services/external-notification-data.js +++ b/static/js/services/external-notification-data.js @@ -47,12 +47,12 @@ function(Config, Features, VulnerabilityService) { events.push({ 'id': 'vulnerability_found', 'title': 'Package Vulnerability Found', - 'icon': 'fa-flag', + 'icon': 'fa-bug', 'fields': [ { 'name': 'level', 'type': 'enum', - 'title': 'Minimum Severity Level', + 'title': 'Minimum Priority Level', 'values': VulnerabilityService.LEVELS, } ] diff --git a/static/partials/image-view.html b/static/partials/image-view.html index 6e4b40767..0693097cb 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -67,45 +67,67 @@
-
+
+
-

Image Security

-
-
This image has not been indexed yet
-
- Please try again in a few minutes. -
-
-
-
This image contains no recognized security vulnerabilities
-
- Quay currently indexes Debian, Red Hat and Ubuntu packages. -
-
- -
- - - - - - - - - - - -
VulnerabilityPriorityDescription
{{ vulnerability.ID }} - - {{ vulnerability.Description }}
- -
-
No matching vulnerabilities found
+

Image Security

+
+
This image has not been indexed yet
- Please adjust your filter above. + Please try again in a few minutes.
+ +
+
This image could not be indexed
+
+ Our security scanner was unable to index this image. +
+
+ +
+
This image contains no recognized security vulnerabilities
+
+ Quay currently indexes Debian, Red Hat and Ubuntu packages. +
+
+ +
+ + + + + + + + + + + +
VulnerabilityPriorityDescription
{{ vulnerability.ID }} + + {{ vulnerability.Description }}
+ +
+
No matching vulnerabilities found
+
+ Please adjust your filter above. +
+
+
+
+ +
@@ -113,23 +135,24 @@
-
+

Image Packages

-
+
This image has not been indexed yet
Please try again in a few minutes.
-
-
This image contains no recognized packages
+ +
+
This image could not be indexed
- Quay currently indexes Debian, Red Hat and Ubuntu packages. + Our security scanner was unable to index this image.
- +
@@ -146,7 +169,7 @@
No matching packages found
-
+
Please adjust your filter above.
diff --git a/static/partials/repo-view.html b/static/partials/repo-view.html index cd3cbca2b..e16ba85ed 100644 --- a/static/partials/repo-view.html +++ b/static/partials/repo-view.html @@ -17,7 +17,8 @@
- + @@ -56,7 +57,8 @@
+ builds="viewScope.builds" + is-enabled="infoShown">
From 37ce84f6af86c9fa5e246d308ebdee9f24a9c11f Mon Sep 17 00:00:00 2001 From: Jimmy Zelinskie Date: Thu, 12 Nov 2015 17:02:18 -0500 Subject: [PATCH 29/36] tiny fixes to securityworker --- util/secscan/api.py | 26 +++++++++++++------------- workers/securityworker.py | 25 ++++++++++++++++++------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/util/secscan/api.py b/util/secscan/api.py index 331e45294..277fd3f6d 100644 --- a/util/secscan/api.py +++ b/util/secscan/api.py @@ -134,13 +134,11 @@ class SecurityConfigValidator(object): return self._keys def valid(self): - config = self._security_config - if (not features.SECURITY_SCANNER - or not config - or not 'ENDPOINT' in config - or not 'ENGINE_VERSION_TARGET' in config - or not 'DISTRIBUTED_STORAGE_PREFERENCE' in config + or not self._security_config + or not 'ENDPOINT' in self._security_config + or not 'ENGINE_VERSION_TARGET' in self._security_config + or not 'DISTRIBUTED_STORAGE_PREFERENCE' in self._security_config or (self._certificate is False and self._keys is None)): return False @@ -155,10 +153,12 @@ class SecurityScannerAPI(object): config_validator = SecurityConfigValidator(app, config_provider) if not config_validator.valid(): + logger.warning('Invalid config provided to SecurityScannerAPI') return - self.certificate = config_validator.cert() - self.keys = config_validator.keypair() + self._security_config = app.config.get('SECURITY_SCANNER') + self._certificate = config_validator.cert() + self._keys = config_validator.keypair() def check_layer_vulnerable(self, layer_id, cve_id): """ Checks with Clair whether the given layer is vulnerable to the given CVE. """ @@ -191,7 +191,7 @@ class SecurityScannerAPI(object): This function disconnects from the database while awaiting a response from the API server. """ - security_config = self.security_config + security_config = self._security_config api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' url = urljoin(api_url, relative_url % args) @@ -201,8 +201,8 @@ class SecurityScannerAPI(object): with CloseForLongOperation(self.app.config): if body is not None: - return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) + return client.post(url, json=body, params=kwargs, timeout=timeout, cert=self._keys, + verify=self._certificate) else: - return client.get(url, params=kwargs, timeout=timeout, cert=self.keys, - verify=self.certificate) + return client.get(url, params=kwargs, timeout=timeout, cert=self._keys, + verify=self._certificate) diff --git a/workers/securityworker.py b/workers/securityworker.py index 1f4328741..664e91472 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -129,6 +129,7 @@ class SecurityWorker(Worker): self._keys = validator.keypair() self.add_operation(self._index_images, INDEXING_INTERVAL) + logger.warning('Failed to validate security scan configuration') def _get_image_url(self, image): """ Gets the download URL for an image and if the storage doesn't exist, @@ -149,9 +150,18 @@ class SecurityWorker(Worker): return None uri = storage.get_direct_download_url(locations, path) - # Local storage hack if uri is None: - uri = path + # Handle local storage + local_storage_enabled = False + for storage_type, _ in app.config.get('DISTRIBUTED_STORAGE_CONFIG', {}).values(): + if storage_type == 'LocalStorage': + local_storage_enabled = True + + if local_storage_enabled: + uri = path + else: + logger.warning('Could not get image URL and local storage was not enabled') + return None return uri @@ -161,12 +171,13 @@ class SecurityWorker(Worker): return None request = { - 'ID': image['docker_image_id']+'.'+image['storage_uuid'], + 'ID': '%s.%s' % (image['docker_image_id'], image['storage_uuid']), 'Path': url, } if image['parent_docker_image_id'] is not None and image['parent_storage_uuid'] is not None: - request['ParentID'] = image['parent_docker_image_id']+'.'+image['parent_storage_uuid'] + request['ParentID'] = '%s.%s' % (image['parent_docker_image_id'], + image['parent_storage_uuid']) return request @@ -182,7 +193,7 @@ class SecurityWorker(Worker): httpResponse = requests.post(self._api + API_METHOD_INSERT, json=request, cert=self._keys, verify=self._cert) jsonResponse = httpResponse.json() - except: + except (requests.exceptions.RequestException, ValueError): logger.exception('An exception occurred when analyzing layer ID %s', request['ID']) return None @@ -245,18 +256,18 @@ class SecurityWorker(Worker): if not images: logger.debug('No more images left to analyze') return - logger.debug('Found %d images to index' % len(images)) + logger.debug('Found %d images to index', len(images)) for image in images: sec_data = self._analyze_image(image) if sec_data is None: continue - logger.debug('Got response vulnerabilities for layer %s: %s', image['image_id'], sec_data) if not sec_data['Vulnerabilities']: continue # Dispatch events for any detected vulnerabilities + logger.debug('Got response vulnerabilities for layer %s: %s', image['image_id'], sec_data) event = ExternalNotificationEvent.get(name='vulnerability_found') matching = (RepositoryTag .select(RepositoryTag, Repository) From 25b8b7590fdf0abb07326e2946a3061c45f156f9 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 17:47:19 -0500 Subject: [PATCH 30/36] Fix all the things! --- data/model/tag.py | 3 +- util/secscan/api.py | 28 +++++++--- workers/security_notification_worker.py | 4 +- workers/securityworker.py | 68 ++++++++++++------------- 4 files changed, 58 insertions(+), 45 deletions(-) diff --git a/data/model/tag.py b/data/model/tag.py index fcaa7f342..002be14b2 100644 --- a/data/model/tag.py +++ b/data/model/tag.py @@ -22,7 +22,8 @@ def get_matching_tags(docker_image_id, storage_uuid, *args): .distinct() .join(Image) .join(ImageStorage) - .where(Image.id << image_query, RepositoryTag.lifetime_end_ts >> None)) + .where(Image.id << image_query, RepositoryTag.lifetime_end_ts >> None, + RepositoryTag.hidden == False)) def list_repository_tags(namespace_name, repository_name, include_hidden=False, diff --git a/util/secscan/api.py b/util/secscan/api.py index 277fd3f6d..5041ec8ff 100644 --- a/util/secscan/api.py +++ b/util/secscan/api.py @@ -134,12 +134,24 @@ class SecurityConfigValidator(object): return self._keys def valid(self): - if (not features.SECURITY_SCANNER - or not self._security_config - or not 'ENDPOINT' in self._security_config - or not 'ENGINE_VERSION_TARGET' in self._security_config - or not 'DISTRIBUTED_STORAGE_PREFERENCE' in self._security_config - or (self._certificate is False and self._keys is None)): + if not features.SECURITY_SCANNER: + return False + + if not self._security_config: + logger.debug('Missing SECURITY_SCANNER block in configuration') + return False + + if not 'ENDPOINT' in self._security_config: + logger.debug('Missing ENDPOINT field in SECURITY_SCANNER configuration') + return False + + endpoint = self._security_config['ENDPOINT'] or '' + if not endpoint.startswith('http://') and not endpoint.startswith('https://'): + logger.debug('ENDPOINT field in SECURITY_SCANNER configuration must start with http or https') + return False + + if endpoint.startswith('https://') and (self._certificate is False or self._keys is None): + logger.debug('Certificate and key pair required for talking to security worker over HTTPS') return False return True @@ -150,6 +162,7 @@ class SecurityScannerAPI(object): def __init__(self, app, config_provider): self.app = app self.config_provider = config_provider + self._security_config = None config_validator = SecurityConfigValidator(app, config_provider) if not config_validator.valid(): @@ -192,6 +205,9 @@ class SecurityScannerAPI(object): from the API server. """ security_config = self._security_config + if security_config is None: + raise Exception('Cannot call unconfigured security system') + api_url = urljoin(security_config['ENDPOINT'], '/' + security_config['API_VERSION']) + '/' url = urljoin(api_url, relative_url % args) diff --git a/workers/security_notification_worker.py b/workers/security_notification_worker.py index 1679e80b6..e54847abf 100644 --- a/workers/security_notification_worker.py +++ b/workers/security_notification_worker.py @@ -18,9 +18,7 @@ logger = logging.getLogger(__name__) class SecurityNotificationWorker(QueueWorker): - def process_queue_item(self, queueitem): - data = json.loads(queueitem.body) - + def process_queue_item(self, data): cve_id = data['Name'] vulnerability = data['Content']['Vulnerability'] priority = vulnerability['Priority'] diff --git a/workers/securityworker.py b/workers/securityworker.py index 664e91472..f6e053c40 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -119,7 +119,7 @@ def _update_image(image, indexed, version): class SecurityWorker(Worker): def __init__(self): super(SecurityWorker, self).__init__() - validator = SecurityConfigValidator(app.config, config_provider) + validator = SecurityConfigValidator(app, config_provider) if validator.valid(): secscan_config = app.config.get('SECURITY_SCANNER') self._api = secscan_config['ENDPOINT'] @@ -143,10 +143,7 @@ class SecurityWorker(Worker): if not storage.exists(locations, path): logger.warning('Could not find a valid location to download layer %s', image['docker_image_id']+'.'+image['storage_uuid']) - try: - _update_image(image, False, self._target_version) - except: - logger.exception('Failed to update unindexed image') + _update_image(image, False, self._target_version) return None uri = storage.get_direct_download_url(locations, path) @@ -182,10 +179,14 @@ class SecurityWorker(Worker): return request def _analyze_image(self, image): + """ Analyzes an image by passing it to Clair. Returns the vulnerabilities detected + (if any) or None on error. + """ request = self._new_request(image) if request is None: return None + # Analyze the image. try: logger.info('Analyzing %s', request['ID']) # Using invalid certificates doesn't return proper errors because of @@ -199,43 +200,36 @@ class SecurityWorker(Worker): # Handle any errors from the security scanner. if httpResponse.status_code != 201: - if 'Message' in jsonResponse: - if 'OS and/or package manager are not supported' in jsonResponse['Message']: - # The current engine could not index this layer - logger.warning('A warning event occurred when analyzing layer ID %s : %s', - request['ID'], jsonResponse['Message']) + if 'OS and/or package manager are not supported' in jsonResponse.get('Message', ''): + # The current engine could not index this layer + logger.warning('A warning event occurred when analyzing layer ID %s : %s', + request['ID'], jsonResponse['Message']) - # Hopefully, there is no version lower than the target one running - try: - _update_image(image, False, self._target_version) - except: - logger.exception('Failed to update image to be unindexed') - else: - logger.warning('Failed to handle JSON message "%s" when analyzing layer ID %s', - jsonResponse['Message'], request['ID']) - return None + # Hopefully, there is no version lower than the target one running + _update_image(image, False, self._target_version) else: - logger.warning('No message found in JSON response when analyzing layer ID %s', request['ID']) - return None + logger.warning('Got non-201 when analyzing layer ID %s: %s', request['ID'], jsonResponse) - api_version = jsonResponse['Version'] - if api_version < self._target_version: - logger.warning('An engine runs on version %d but the target version is %d') + return None - try: - _update_image(image, True, api_version) - logger.debug('Layer %s analyzed successfully', request['ID']) - except: - logger.exception('Failed to update image to be indexed') + # Verify that the version matches. + api_version = jsonResponse['Version'] + if api_version < self._target_version: + logger.warning('An engine runs on version %d but the target version is %d') - logger.debug('Loading vulnerabilities for layer %s', image['image_id']) + # Mark the image as analyzed. + logger.debug('Layer %s analyzed successfully; Loading vulnerabilities for layer', + image['image_id']) + _update_image(image, True, api_version) + + # Lookup the vulnerabilities for the image, now that it is analyzed. try: response = secscan_api.call('layers/%s/vulnerabilities', None, request['ID']) logger.debug('Got response %s for vulnerabilities for layer %s', response.status_code, image['image_id']) if response.status_code == 404: return None - except: + except (requests.exceptions.RequestException, ValueError): logger.exception('Failed to get vulnerability response for %s', image['image_id']) return None @@ -246,6 +240,7 @@ class SecurityWorker(Worker): with UseThenDisconnect(app.config): while True: + # Lookup the images to index. images = [] try: logger.debug('Looking up images to index') @@ -256,9 +251,10 @@ class SecurityWorker(Worker): if not images: logger.debug('No more images left to analyze') return - logger.debug('Found %d images to index', len(images)) + logger.debug('Found %d images to index', len(images)) for image in images: + # Analyze the image, retrieving the vulnerabilities (if any). sec_data = self._analyze_image(image) if sec_data is None: continue @@ -267,7 +263,7 @@ class SecurityWorker(Worker): continue # Dispatch events for any detected vulnerabilities - logger.debug('Got response vulnerabilities for layer %s: %s', image['image_id'], sec_data) + logger.debug('Got vulnerabilities for layer %s: %s', image['image_id'], sec_data) event = ExternalNotificationEvent.get(name='vulnerability_found') matching = (RepositoryTag .select(RepositoryTag, Repository) @@ -275,9 +271,11 @@ class SecurityWorker(Worker): .join(Repository) .join(RepositoryNotification) .where(RepositoryNotification.event == event, - RepositoryTag.image == image['image_id'])) + RepositoryTag.image == image['image_id'], + RepositoryTag.hidden == False, + RepositoryTag.lifetime_end_ts >> None)) - repository_map = defaultdict() + repository_map = defaultdict(list) for tag in matching: repository_map[tag.repository_id].append(tag) From 030c69d7d295eb96ea10074483edd9da60ba4e32 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 21:59:52 -0500 Subject: [PATCH 31/36] Further merge fixes --- data/database.py | 4 ---- initdb.py | 4 ---- workers/securityworker.py | 42 ++++++++++++++++++++------------------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/data/database.py b/data/database.py index d1431eeed..00735b49b 100644 --- a/data/database.py +++ b/data/database.py @@ -576,10 +576,6 @@ class Image(BaseModel): security_indexed_engine = IntegerField(default=-1) parent_id = IntegerField(index=True, null=True) - security_indexed = BooleanField(default=False) - security_indexed_engine = IntegerField(default=-1) - parent = ForeignKeyField('self', index=True, null=True, related_name='children') - class Meta: database = db read_slaves = (read_slave,) diff --git a/initdb.py b/initdb.py index de97ec691..ce51c1749 100644 --- a/initdb.py +++ b/initdb.py @@ -99,10 +99,6 @@ def __create_subtree(repo, structure, creator_username, parent, tag_map): new_image.security_indexed_engine = -1 new_image.save() - new_image.security_indexed = False - new_image.security_indexed_engine = maxsize - new_image.save() - creation_time = REFERENCE_DATE + timedelta(weeks=image_num) + timedelta(days=model_num) command_list = SAMPLE_CMDS[image_num % len(SAMPLE_CMDS)] command = json.dumps(command_list) if command_list else None diff --git a/workers/securityworker.py b/workers/securityworker.py index 7989821ed..11492fbf2 100644 --- a/workers/securityworker.py +++ b/workers/securityworker.py @@ -31,14 +31,13 @@ def _get_images_to_export_list(version): # Collect the images without parents candidates = (Image - .select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum) - .join(ImageStorage) - .where(Image.security_indexed_engine < version, - Image.parent_id >> None, - ImageStorage.uploading == False, - ImageStorage.checksum != '') - .limit(BATCH_SIZE*10) - .alias('candidates')) + .select(Image.id, Image.docker_image_id, ImageStorage.uuid) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, + Image.parent_id >> None, + ImageStorage.uploading == False) + .limit(BATCH_SIZE*10) + .alias('candidates')) images = (Image .select(candidates.c.id, candidates.c.docker_image_id, candidates.c.uuid) @@ -56,18 +55,21 @@ def _get_images_to_export_list(version): # Collect the images with analyzed parents. candidates = (Image - .select(Image.docker_image_id, ImageStorage.uuid, ImageStorage.checksum, Parent.docker_image_id.alias('parent_docker_image_id'), ParentImageStorage.uuid.alias('parent_storage_uuid')) - .join(Parent, on=(Image.parent_id == Parent.id)) - .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) - .switch(Image) - .join(ImageStorage) - .where(Image.security_indexed_engine < version, - Parent.security_indexed == True, - Parent.security_indexed_engine >= version, - ImageStorage.uploading == False, - ImageStorage.checksum != '') - .limit(BATCH_SIZE*10) - .alias('candidates')) + .select(Image.id, + Image.docker_image_id, + ImageStorage.uuid, + Parent.docker_image_id.alias('parent_docker_image_id'), + ParentImageStorage.uuid.alias('parent_storage_uuid')) + .join(Parent, on=(Image.parent_id == Parent.id)) + .join(ParentImageStorage, on=(ParentImageStorage.id == Parent.storage)) + .switch(Image) + .join(ImageStorage) + .where(Image.security_indexed_engine < version, + Parent.security_indexed == True, + Parent.security_indexed_engine >= version, + ImageStorage.uploading == False) + .limit(BATCH_SIZE*10) + .alias('candidates')) images = (Image .select(candidates.c.id, From 819d461ed693d94e4d51cfa4f996f5048bdc61b3 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 22:02:26 -0500 Subject: [PATCH 32/36] Remove migration re-added by merge accidentally --- ...c20cc_backfill_parent_ids_and_checksums.py | 21 ------------------- 1 file changed, 21 deletions(-) delete mode 100644 data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py diff --git a/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py b/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py deleted file mode 100644 index afd589809..000000000 --- a/data/migrations/versions/2fb9492c20cc_backfill_parent_ids_and_checksums.py +++ /dev/null @@ -1,21 +0,0 @@ -"""backfill parent ids and checksums -Revision ID: 2fb9492c20cc -Revises: 57dad559ff2d -Create Date: 2015-07-14 17:38:47.397963 -""" - -# revision identifiers, used by Alembic. -revision = '2fb9492c20cc' -down_revision = '57dad559ff2d' - -from alembic import op -import sqlalchemy as sa -from util.migrate.backfill_parent_id import backfill_parent_id -from util.migrate.backfill_checksums import backfill_checksums - -def upgrade(tables): - backfill_parent_id() - backfill_checksums() - -def downgrade(tables): - pass From b7206a8cfc6c31056a3f9971de9e9120b7a68500 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 22:03:13 -0500 Subject: [PATCH 33/36] Remove file added accidentally by merge --- endpoints/api/sec.py | 105 ------------------------------------------- 1 file changed, 105 deletions(-) delete mode 100644 endpoints/api/sec.py diff --git a/endpoints/api/sec.py b/endpoints/api/sec.py deleted file mode 100644 index 4e77750f9..000000000 --- a/endpoints/api/sec.py +++ /dev/null @@ -1,105 +0,0 @@ -""" List and manage repository vulnerabilities and other sec information. """ - -import logging -import features -import json -import requests - -from app import secscan_api -from data import model -from endpoints.api import (require_repo_read, NotFound, DownstreamIssue, path_param, - RepositoryParamResource, resource, nickname, show_if, parse_args, - query_param) - - -logger = logging.getLogger(__name__) - - -def _call_security_api(relative_url, *args, **kwargs): - """ Issues an HTTP call to the sec API at the given relative URL. """ - try: - response = secscan_api.call(relative_url, *args, **kwargs) - except requests.exceptions.Timeout: - raise DownstreamIssue(payload=dict(message='API call timed out')) - except requests.exceptions.ConnectionError: - raise DownstreamIssue(payload=dict(message='Could not connect to downstream service')) - - if response.status_code == 404: - raise NotFound() - - try: - response_data = json.loads(response.text) - except ValueError: - raise DownstreamIssue(payload=dict(message='Non-json response from downstream service')) - - if response.status_code / 100 != 2: - logger.warning('Got %s status code to call: %s', response.status_code, response.text) - raise DownstreamIssue(payload=dict(message=response_data['Message'])) - - return response_data - - -@show_if(features.SECURITY_SCANNER) -@resource('/v1/repository//tag//vulnerabilities') -@path_param('repository', 'The full path of the repository. e.g. namespace/name') -@path_param('tag', 'The name of the tag') -class RepositoryTagVulnerabilities(RepositoryParamResource): - """ Operations for managing the vulnerabilities in a repository tag. """ - - @require_repo_read - @nickname('getRepoTagVulnerabilities') - @parse_args - @query_param('minimumPriority', 'Minimum vulnerability priority', type=str, - default='Low') - def get(self, args, namespace, repository, tag): - """ Fetches the vulnerabilities (if any) for a repository tag. """ - try: - tag_image = model.tag.get_tag_image(namespace, repository, tag) - except model.DataModelException: - raise NotFound() - - if not tag_image.security_indexed: - logger.debug('Image %s for tag %s under repository %s/%s not security indexed', - tag_image.docker_image_id, tag, namespace, repository) - return { - 'security_indexed': False - } - - data = _call_security_api('layers/%s.%s/vulnerabilities', tag_image.docker_image_id, - tag_image.storage.uuid, - minimumPriority=args.minimumPriority) - - return { - 'security_indexed': True, - 'data': data, - } - - -@show_if(features.SECURITY_SCANNER) -@resource('/v1/repository//image//packages') -@path_param('repository', 'The full path of the repository. e.g. namespace/name') -@path_param('imageid', 'The image ID') -class RepositoryImagePackages(RepositoryParamResource): - """ Operations for listing the packages in an image. """ - - @require_repo_read - @nickname('getRepoImagePackages') - def get(self, namespace, repository, imageid): - """ Fetches the packages added/removed in the given repo image. """ - repo_image = model.image.get_repo_image(namespace, repository, imageid) - if repo_image is None: - raise NotFound() - - if not repo_image.security_indexed: - return { - 'security_indexed': False - } - - data = _call_security_api('layers/%s.%s/packages', repo_image.docker_image_id, - repo_image.storage.uuid) - - return { - 'security_indexed': True, - 'data': data, - } - From 46745ee30f82438c29e87502c898c05212782ecb Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 22:07:47 -0500 Subject: [PATCH 34/36] Remove file added accidentally by merge --- util/migrate/backfill_checksums.py | 67 ------------------------------ 1 file changed, 67 deletions(-) delete mode 100644 util/migrate/backfill_checksums.py diff --git a/util/migrate/backfill_checksums.py b/util/migrate/backfill_checksums.py deleted file mode 100644 index 42c1fed44..000000000 --- a/util/migrate/backfill_checksums.py +++ /dev/null @@ -1,67 +0,0 @@ -import logging -from app import storage as store -from data.database import ImageStorage, ImageStoragePlacement, ImageStorageLocation, JOIN_LEFT_OUTER -from digest import checksums - -logger = logging.getLogger(__name__) - -def _get_imagestorages_with_locations(query_modifier): - query = (ImageStoragePlacement - .select(ImageStoragePlacement, ImageStorage, ImageStorageLocation) - .join(ImageStorageLocation) - .switch(ImageStoragePlacement) - .join(ImageStorage, JOIN_LEFT_OUTER)) - query = query_modifier(query) - - location_list = list(query) - - storages = {} - for location in location_list: - storage = location.storage - - if not storage.id in storages: - storages[storage.id] = storage - storage.locations = set() - else: - storage = storages[storage.id] - - storage.locations.add(location.location.name) - - return storages.values() - -def backfill_checksum(imagestorage_with_locations): - try: - json_data = store.get_content(imagestorage_with_locations.locations, store.image_json_path(imagestorage_with_locations.uuid)) - with store.stream_read_file(imagestorage_with_locations.locations, store.image_layer_path(imagestorage_with_locations.uuid)) as fp: - imagestorage_with_locations.checksum = 'sha256:{0}'.format(checksums.sha256_file(fp, json_data + '\n')) - imagestorage_with_locations.save() - except IOError as e: - if str(e).startswith("No such key"): - imagestorage_with_locations.checksum = 'unknown:{0}'.format(imagestorage_with_locations.uuid) - imagestorage_with_locations.save() - except: - logger.exception('exception when backfilling checksum of %s', imagestorage_with_locations.uuid) - -def backfill_checksums(): - logger.setLevel(logging.DEBUG) - - logger.debug('backfill_checksums: Starting') - logger.debug('backfill_checksums: This can be a LONG RUNNING OPERATION. Please wait!') - - def limit_to_empty_checksum(query): - return query.where(ImageStorage.checksum >> None, ImageStorage.uploading == False).limit(100) - - while True: - storages = _get_imagestorages_with_locations(limit_to_empty_checksum) - if len(storages) == 0: - logger.debug('backfill_checksums: Completed') - return - - for storage in storages: - backfill_checksum(storage) - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - logging.getLogger('peewee').setLevel(logging.CRITICAL) - logging.getLogger('boto').setLevel(logging.CRITICAL) - backfill_checksums() From da07823e20a856849e7c8ddf57ef6303bd77ab26 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 22:28:22 -0500 Subject: [PATCH 35/36] Small test fix --- test/test_api_security.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_api_security.py b/test/test_api_security.py index 290008f2b..a50a21bda 100644 --- a/test/test_api_security.py +++ b/test/test_api_security.py @@ -49,7 +49,7 @@ from endpoints.api.superuser import (SuperUserLogs, SuperUserList, SuperUserMana SuperUserSendRecoveryEmail, ChangeLog, SuperUserOrganizationManagement, SuperUserOrganizationList, SuperUserAggregateLogs) -from endpoints.api.secscan import RepositoryImagePackages, RepositoryTagVulnerabilities +from endpoints.api.secscan import RepositoryImagePackages, RepositoryImageVulnerabilities try: @@ -4224,10 +4224,10 @@ class TestOrganizationInvoiceField(ApiTestCase): self._run_test('DELETE', 201, 'devtable', None) -class TestRepositoryTagVulnerabilities(ApiTestCase): +class TestRepositoryImageVulnerabilities(ApiTestCase): def setUp(self): ApiTestCase.setUp(self) - self._set_url(RepositoryTagVulnerabilities, repository='devtable/simple', tag='latest') + self._set_url(RepositoryImageVulnerabilities, repository='devtable/simple', imageid='fake') def test_get_anonymous(self): self._run_test('GET', 401, None, None) @@ -4239,7 +4239,7 @@ class TestRepositoryTagVulnerabilities(ApiTestCase): self._run_test('GET', 403, 'reader', None) def test_get_devtable(self): - self._run_test('GET', 200, 'devtable', None) + self._run_test('GET', 404, 'devtable', None) class TestRepositoryImagePackages(ApiTestCase): From 49ab87bab4758754a9d09c11589889670b9c272d Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 12 Nov 2015 22:45:52 -0500 Subject: [PATCH 36/36] Fix log permissions --- conf/init/service/security_notification_worker/log/run | 0 conf/init/service/securityworker/log/run | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 conf/init/service/security_notification_worker/log/run mode change 100644 => 100755 conf/init/service/securityworker/log/run diff --git a/conf/init/service/security_notification_worker/log/run b/conf/init/service/security_notification_worker/log/run old mode 100644 new mode 100755 diff --git a/conf/init/service/securityworker/log/run b/conf/init/service/securityworker/log/run old mode 100644 new mode 100755
Package Name Package Version